mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 09:43:43 +00:00
184 lines
4.6 KiB
Go
184 lines
4.6 KiB
Go
package core
|
||
|
||
import (
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// analyticsBar is one day's column in the traffic charts; heights are percentages
|
||
// of the busiest day so the template just sets a CSS height.
|
||
type analyticsBar struct {
|
||
Label string
|
||
Requests int64
|
||
Visitors int64
|
||
ReqH int
|
||
VisH int
|
||
}
|
||
|
||
// analyticsRow is a labeled value with a bar width (percent of the max), used for
|
||
// the by-surface and top-paths tables.
|
||
type analyticsRow struct {
|
||
Label string
|
||
Value int64
|
||
W int
|
||
}
|
||
|
||
// pageAnalytics renders the traffic dashboard from the rolled-up aggregate tables.
|
||
func (s *Handlers) pageAnalytics(w http.ResponseWriter, r *http.Request) {
|
||
days := 30
|
||
switch r.URL.Query().Get("days") {
|
||
case "7":
|
||
days = 7
|
||
case "90":
|
||
days = 90
|
||
}
|
||
|
||
daily, err := s.Store.AnalyticsDaily(r.Context(), days)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load analytics", err)
|
||
return
|
||
}
|
||
surfaces, err := s.Store.AnalyticsBySurface(r.Context(), days)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load analytics", err)
|
||
return
|
||
}
|
||
paths, err := s.Store.AnalyticsTopPaths(r.Context(), days, 20)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load analytics", err)
|
||
return
|
||
}
|
||
hosts, err := s.Store.AnalyticsTopHosts(r.Context(), days, 15)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load analytics", err)
|
||
return
|
||
}
|
||
visitors, err := s.Store.AnalyticsTopVisitors(r.Context(), days, 15)
|
||
if err != nil {
|
||
s.ServerError(w, r, "could not load analytics", err)
|
||
return
|
||
}
|
||
|
||
var maxReq, maxVis, totalReq int64
|
||
for _, d := range daily {
|
||
totalReq += d.Requests
|
||
if d.Requests > maxReq {
|
||
maxReq = d.Requests
|
||
}
|
||
if d.Visitors > maxVis {
|
||
maxVis = d.Visitors
|
||
}
|
||
}
|
||
// The daily chart buckets are aggregated per UTC calendar day (see the store
|
||
// query), so their axis labels and the "is today" check below stay in UTC —
|
||
// unlike discrete instants (LastSeen), these are day buckets, not moments to
|
||
// localize per viewer.
|
||
bars := make([]analyticsBar, 0, len(daily))
|
||
for _, d := range daily {
|
||
bars = append(bars, analyticsBar{
|
||
Label: d.Day.Format("1/2"),
|
||
Requests: d.Requests,
|
||
Visitors: d.Visitors,
|
||
ReqH: barPct(d.Requests, maxReq),
|
||
VisH: barPct(d.Visitors, maxVis),
|
||
})
|
||
}
|
||
|
||
// "Today" only if the latest rolled-up day is actually today (UTC).
|
||
var todayReq, todayVis int64
|
||
if n := len(daily); n > 0 && daily[n-1].Day.UTC().Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
|
||
todayReq, todayVis = daily[n-1].Requests, daily[n-1].Visitors
|
||
}
|
||
|
||
var maxSurface int64
|
||
for _, x := range surfaces {
|
||
if x.Requests > maxSurface {
|
||
maxSurface = x.Requests
|
||
}
|
||
}
|
||
surfaceRows := make([]analyticsRow, 0, len(surfaces))
|
||
for _, x := range surfaces {
|
||
surfaceRows = append(surfaceRows, analyticsRow{Label: x.Surface, Value: x.Requests, W: barPct(x.Requests, maxSurface)})
|
||
}
|
||
|
||
var maxHits int64
|
||
for _, p := range paths {
|
||
if p.Hits > maxHits {
|
||
maxHits = p.Hits
|
||
}
|
||
}
|
||
pathRows := make([]analyticsRow, 0, len(paths))
|
||
for _, p := range paths {
|
||
pathRows = append(pathRows, analyticsRow{Label: p.Path, Value: p.Hits, W: barPct(p.Hits, maxHits)})
|
||
}
|
||
|
||
var maxHostReq int64
|
||
for _, h := range hosts {
|
||
if h.Requests > maxHostReq {
|
||
maxHostReq = h.Requests
|
||
}
|
||
}
|
||
hostRows := make([]analyticsRow, 0, len(hosts))
|
||
for _, h := range hosts {
|
||
label := h.Host
|
||
if label == "" {
|
||
label = "(none)"
|
||
}
|
||
hostRows = append(hostRows, analyticsRow{Label: label, Value: h.Requests, W: barPct(h.Requests, maxHostReq)})
|
||
}
|
||
|
||
var maxVisReq int64
|
||
for _, v := range visitors {
|
||
if v.Requests > maxVisReq {
|
||
maxVisReq = v.Requests
|
||
}
|
||
}
|
||
visitorRows := make([]visitorRow, 0, len(visitors))
|
||
for _, v := range visitors {
|
||
visitorRows = append(visitorRows, visitorRow{
|
||
Visitor: v.Visitor,
|
||
Surface: v.Surface,
|
||
LastPath: v.LastPath,
|
||
LastSeen: v.LastSeen,
|
||
Requests: v.Requests,
|
||
W: barPct(v.Requests, maxVisReq),
|
||
})
|
||
}
|
||
|
||
s.Render(w, r, "analytics.html", map[string]any{
|
||
"Days": days,
|
||
"Bars": bars,
|
||
"Surfaces": surfaceRows,
|
||
"Paths": pathRows,
|
||
"Hosts": hostRows,
|
||
"Visitors": visitorRows,
|
||
"TotalReq": totalReq,
|
||
"TodayReq": todayReq,
|
||
"TodayVis": todayVis,
|
||
"HasData": len(daily) > 0,
|
||
})
|
||
}
|
||
|
||
// visitorRow is one (daily-rotating) visitor hash for the "traffic by user" table.
|
||
type visitorRow struct {
|
||
Visitor string
|
||
Surface string
|
||
LastPath string
|
||
LastSeen time.Time // rendered client-side (localized) via the `ts` template func
|
||
Requests int64
|
||
W int
|
||
}
|
||
|
||
// barPct scales v to a 0–100 height/width, with a small floor so non-zero values
|
||
// stay visible.
|
||
func barPct(v, max int64) int {
|
||
if max <= 0 || v <= 0 {
|
||
return 0
|
||
}
|
||
p := int(v * 100 / max)
|
||
if p < 2 {
|
||
p = 2
|
||
}
|
||
return p
|
||
}
|