mirror of
https://github.com/Kugelschieber/marvinblum.git
synced 2026-01-18 06:40:27 +00:00
Total visitor and page visits statistics page.
This commit is contained in:
2
go.mod
2
go.mod
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/emvi/pirsch v0.0.0-20200624123353-86381b017755
|
github.com/emvi/pirsch v0.0.0-20200624123353-86381b017755
|
||||||
github.com/gorilla/mux v1.7.4
|
github.com/gorilla/mux v1.7.4
|
||||||
github.com/gosimple/slug v1.9.0
|
github.com/gosimple/slug v1.9.0
|
||||||
github.com/jmoiron/sqlx v1.2.0 // indirect
|
github.com/jmoiron/sqlx v1.2.0
|
||||||
github.com/klauspost/cpuid v1.3.0 // indirect
|
github.com/klauspost/cpuid v1.3.0 // indirect
|
||||||
github.com/lib/pq v1.7.0
|
github.com/lib/pq v1.7.0
|
||||||
github.com/miekg/dns v1.1.29 // indirect
|
github.com/miekg/dns v1.1.29 // indirect
|
||||||
|
|||||||
27
main.go
27
main.go
@@ -16,6 +16,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -116,6 +117,31 @@ func serveBlogArticle() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveTracking() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start, _ := strconv.Atoi(r.URL.Query().Get("start"))
|
||||||
|
|
||||||
|
if start > 365 {
|
||||||
|
start = 365
|
||||||
|
} else if start < 7 {
|
||||||
|
start = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
totalVisitorsLabels, totalVisitorsDps := tracking.GetTotalVisitors(start)
|
||||||
|
tplCache.RenderWithoutCache(w, "tracking.html", struct {
|
||||||
|
Start int
|
||||||
|
TotalVisitorsLabels template.JS
|
||||||
|
TotalVisitorsDps template.JS
|
||||||
|
PageVisits []tracking.PageVisits
|
||||||
|
}{
|
||||||
|
start,
|
||||||
|
totalVisitorsLabels,
|
||||||
|
totalVisitorsDps,
|
||||||
|
tracking.GetPageVisits(start),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func serveNotFound() http.HandlerFunc {
|
func serveNotFound() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
tplCache.Render(w, "notfound.html", nil)
|
tplCache.Render(w, "notfound.html", nil)
|
||||||
@@ -128,6 +154,7 @@ func setupRouter() *mux.Router {
|
|||||||
router.Handle("/blog/{slug}", serveBlogArticle())
|
router.Handle("/blog/{slug}", serveBlogArticle())
|
||||||
router.Handle("/blog", serveBlogPage())
|
router.Handle("/blog", serveBlogPage())
|
||||||
router.Handle("/legal", serveLegal())
|
router.Handle("/legal", serveLegal())
|
||||||
|
router.Handle("/tracking", serveTracking())
|
||||||
router.Handle("/", serveAbout())
|
router.Handle("/", serveAbout())
|
||||||
router.NotFoundHandler = serveNotFound()
|
router.NotFoundHandler = serveNotFound()
|
||||||
return router
|
return router
|
||||||
|
|||||||
7
static/js/Chart-v2.9.3.bundle.min.js
vendored
Normal file
7
static/js/Chart-v2.9.3.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -47,6 +47,11 @@ body {
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
margin: 80px 0;
|
margin: 80px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
71
template/tracking.html
Normal file
71
template/tracking.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{{template "head.html"}}
|
||||||
|
{{template "menu.html"}}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1>Tracking</h1>
|
||||||
|
<p>
|
||||||
|
This page shows tracking statistics for my website using <a href="https://github.com/emvi/pirsch" target="_blank">Pirsch</a> and <a href="https://www.chartjs.org/" target="_blank">Chart.Js</a>. The data shows unique visitors.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="/tracking?start=7" class="button {{if eq .Start 7}}filled{{end}}">Week</a>
|
||||||
|
<a href="/tracking?start=30" class="button {{if eq .Start 30}}filled{{end}}">Month</a>
|
||||||
|
<a href="/tracking?start=90" class="button {{if eq .Start 90}}filled{{end}}">Quarter</a>
|
||||||
|
<a href="/tracking?start=182" class="button {{if eq .Start 182}}filled{{end}}">Half Year</a>
|
||||||
|
<a href="/tracking?start=365" class="button {{if eq .Start 365}}filled{{end}}">Year</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Total Visitors</h2>
|
||||||
|
<canvas id="totalVisitors" class="tracking"></canvas>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Pages Visits</h2>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{range $i, $data := .PageVisits}}
|
||||||
|
<section>
|
||||||
|
<h3>{{$data.Path}}</h3>
|
||||||
|
<canvas id="pageVisits{{$i}}" class="tracking"></canvas>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- TODO -->
|
||||||
|
<!--<section>
|
||||||
|
<h2>Languages</h2>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Visitors Per Hour</h2>
|
||||||
|
</section>-->
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/static/js/Chart-v2.9.3.bundle.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
new Chart(document.getElementById('totalVisitors').getContext('2d'), {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: [{{.TotalVisitorsLabels}}],
|
||||||
|
datasets: [{
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
||||||
|
borderColor: "#000",
|
||||||
|
label: "Total Visitors",
|
||||||
|
data: [{{.TotalVisitorsDps}}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{{range $i, $data := .PageVisits}}
|
||||||
|
new Chart(document.getElementById('pageVisits{{$i}}').getContext('2d'), {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: [{{$data.Labels}}],
|
||||||
|
datasets: [{
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
||||||
|
borderColor: "#000",
|
||||||
|
label: "Page Visits",
|
||||||
|
data: [{{$data.Data}}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{{end}}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{template "end.html"}}
|
||||||
@@ -27,6 +27,7 @@ func NewCache() *Cache {
|
|||||||
cache: make(map[string][]byte),
|
cache: make(map[string][]byte),
|
||||||
hotReload: os.Getenv("MB_HOT_RELOAD") == "true",
|
hotReload: os.Getenv("MB_HOT_RELOAD") == "true",
|
||||||
}
|
}
|
||||||
|
logbuch.Debug("Template cache hot reload", logbuch.Fields{"hot_reload": cache.hotReload})
|
||||||
cache.load()
|
cache.load()
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,12 @@ func (cache *Cache) Render(w http.ResponseWriter, name string, data interface{})
|
|||||||
cache.m.Lock()
|
cache.m.Lock()
|
||||||
defer cache.m.Unlock()
|
defer cache.m.Unlock()
|
||||||
logbuch.Debug("Rendering template", logbuch.Fields{"name": name})
|
logbuch.Debug("Rendering template", logbuch.Fields{"name": name})
|
||||||
|
|
||||||
|
if cache.hotReload {
|
||||||
|
logbuch.Debug("Reloading templates")
|
||||||
|
cache.load()
|
||||||
|
}
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
|
|
||||||
if err := cache.tpl.ExecuteTemplate(&buffer, name, data); err != nil {
|
if err := cache.tpl.ExecuteTemplate(&buffer, name, data); err != nil {
|
||||||
@@ -75,6 +82,18 @@ func (cache *Cache) Render(w http.ResponseWriter, name string, data interface{})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cache *Cache) RenderWithoutCache(w http.ResponseWriter, name string, data interface{}) {
|
||||||
|
if cache.hotReload {
|
||||||
|
logbuch.Debug("Reloading templates")
|
||||||
|
cache.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cache.tpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
logbuch.Error("Error executing template", logbuch.Fields{"err": err, "name": name})
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (cache *Cache) Clear() {
|
func (cache *Cache) Clear() {
|
||||||
cache.m.Lock()
|
cache.m.Lock()
|
||||||
defer cache.m.Unlock()
|
defer cache.m.Unlock()
|
||||||
|
|||||||
102
tracking/statistics.go
Normal file
102
tracking/statistics.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package tracking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/emvi/pirsch"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
statisticsDateFormat = "2006-01-02"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PageVisits struct {
|
||||||
|
Path string
|
||||||
|
Labels template.JS
|
||||||
|
Data template.JS
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTotalVisitors(start int) (template.JS, template.JS) {
|
||||||
|
query := `SELECT "date" "day",
|
||||||
|
CASE WHEN "visitors_per_day".visitors IS NULL THEN 0 ELSE "visitors_per_day".visitors END
|
||||||
|
FROM (SELECT * FROM generate_series(date($1), date(now()), interval '1 day') "date") AS date_series
|
||||||
|
LEFT JOIN "visitors_per_day" ON date("visitors_per_day"."day") = date("date")
|
||||||
|
ORDER BY "date" ASC`
|
||||||
|
startTime := today()
|
||||||
|
startTime = startTime.Add(-time.Hour * 24 * time.Duration(start-1))
|
||||||
|
logbuch.Debug("Reading total visitors since", logbuch.Fields{"since": startTime})
|
||||||
|
var visitors []pirsch.VisitorsPerDay
|
||||||
|
|
||||||
|
if err := db.Select(&visitors, query, startTime); err != nil {
|
||||||
|
logbuch.Error("Error reading total visitors", logbuch.Fields{"err": err, "since": startTime})
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
today := today()
|
||||||
|
visitorsToday, err := store.VisitorsPerDay(today)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("Error reading total visitors for today", logbuch.Fields{"err": err, "since": startTime})
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
visitors[len(visitors)-1].Visitors = visitorsToday
|
||||||
|
return getLabelsAndData(visitors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPageVisits(start int) []PageVisits {
|
||||||
|
pathsQuery := `SELECT * FROM (SELECT DISTINCT "path" FROM "visitors_per_page" WHERE day >= $1) AS paths ORDER BY length("path") ASC`
|
||||||
|
query := `SELECT "date" "day",
|
||||||
|
CASE WHEN "visitors_per_page".visitors IS NULL THEN 0 ELSE "visitors_per_page".visitors END
|
||||||
|
FROM (SELECT * FROM generate_series(date($1), date(now() - INTERVAL '1 day'), interval '1 day') "date") AS date_series
|
||||||
|
LEFT JOIN "visitors_per_page" ON date("visitors_per_page"."day") = date("date")
|
||||||
|
WHERE "visitors_per_page" IS NULL OR "visitors_per_page"."path" = $2
|
||||||
|
ORDER BY "date" ASC, length("visitors_per_page"."path") ASC`
|
||||||
|
startTime := today()
|
||||||
|
startTime = startTime.Add(-time.Hour * 24 * time.Duration(start-1))
|
||||||
|
logbuch.Debug("Reading page visits", logbuch.Fields{"since": startTime})
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
if err := db.Select(&paths, pathsQuery, startTime); err != nil {
|
||||||
|
logbuch.Error("Error reading distinct paths", logbuch.Fields{"err": err})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pageVisits := make([]PageVisits, len(paths))
|
||||||
|
|
||||||
|
for i, path := range paths {
|
||||||
|
var visitors []pirsch.VisitorsPerDay
|
||||||
|
|
||||||
|
if err := db.Select(&visitors, query, startTime, path); err != nil {
|
||||||
|
logbuch.Error("Error reading visitor for path", logbuch.Fields{"err": err, "since": startTime})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels, data := getLabelsAndData(visitors)
|
||||||
|
pageVisits[i] = PageVisits{path, labels, data}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageVisits
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLabelsAndData(visitors []pirsch.VisitorsPerDay) (template.JS, template.JS) {
|
||||||
|
var labels strings.Builder
|
||||||
|
var dp strings.Builder
|
||||||
|
|
||||||
|
for _, point := range visitors {
|
||||||
|
labels.WriteString(fmt.Sprintf("'%s',", point.Day.Format(statisticsDateFormat)))
|
||||||
|
dp.WriteString(fmt.Sprintf("%d,", point.Visitors))
|
||||||
|
}
|
||||||
|
|
||||||
|
labelsStr := labels.String()
|
||||||
|
dataStr := dp.String()
|
||||||
|
return template.JS(labelsStr[:len(labelsStr)-1]), template.JS(dataStr[:len(dataStr)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func today() time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/emvi/pirsch"
|
"github.com/emvi/pirsch"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +15,9 @@ const (
|
|||||||
connectionString = `host=%s port=%s user=%s password=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s connectTimeout=%s timezone=%s`
|
connectionString = `host=%s port=%s user=%s password=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s connectTimeout=%s timezone=%s`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var db *sqlx.DB
|
||||||
|
var store pirsch.Store
|
||||||
|
|
||||||
func NewTracker() *pirsch.Tracker {
|
func NewTracker() *pirsch.Tracker {
|
||||||
logbuch.Info("Connecting to database...")
|
logbuch.Info("Connecting to database...")
|
||||||
host := os.Getenv("MB_DB_HOST")
|
host := os.Getenv("MB_DB_HOST")
|
||||||
@@ -29,26 +33,26 @@ func NewTracker() *pirsch.Tracker {
|
|||||||
timezone := zone + strconv.Itoa(-offset/3600)
|
timezone := zone + strconv.Itoa(-offset/3600)
|
||||||
logbuch.Info("Setting time zone", logbuch.Fields{"timezone": timezone})
|
logbuch.Info("Setting time zone", logbuch.Fields{"timezone": timezone})
|
||||||
connectionStr := fmt.Sprintf(connectionString, host, port, user, password, schema, sslMode, sslCert, sslKey, sslRootCert, "30", timezone)
|
connectionStr := fmt.Sprintf(connectionString, host, port, user, password, schema, sslMode, sslCert, sslKey, sslRootCert, "30", timezone)
|
||||||
db, err := sql.Open("postgres", connectionStr)
|
conn, err := sql.Open("postgres", connectionStr)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Fatal("Error connecting to database", logbuch.Fields{"err": err})
|
logbuch.Fatal("Error connecting to database", logbuch.Fields{"err": err})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
if err := conn.Ping(); err != nil {
|
||||||
logbuch.Fatal("Error pinging database", logbuch.Fields{"err": err})
|
logbuch.Fatal("Error pinging database", logbuch.Fields{"err": err})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
store := pirsch.NewPostgresStore(db)
|
db = sqlx.NewDb(conn, "postgres")
|
||||||
|
store = pirsch.NewPostgresStore(conn)
|
||||||
tracker := pirsch.NewTracker(store, nil)
|
tracker := pirsch.NewTracker(store, nil)
|
||||||
processor := pirsch.NewProcessor(store)
|
processor := pirsch.NewProcessor(store)
|
||||||
processTrackingData(processor)
|
processTrackingData(processor)
|
||||||
pirsch.RunAtMidnight(func() {
|
pirsch.RunAtMidnight(func() {
|
||||||
processTrackingData(processor)
|
processTrackingData(processor)
|
||||||
})
|
})
|
||||||
|
|
||||||
return tracker
|
return tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user