Phase 7: telemetry and usage (daily rollup + week dashboard)
Build on Phases 1–6. Telemetry already arrived on
restrict/<device>/telemetry; this phase stores it in a daily rollup and
shows it.
- store: migration 003 adds telemetry_daily (device, day, app seconds,
site seconds). InsertTelemetry maintains the affected days in the same
transaction; RebuildRollups backfills and repairs it. UsageRange and
TopUsage serve the week grid and top lists. CapAudit enforces the
size-capped append-only audit log from AGENTS.md §7.
- web: GET /devices/{id}/usage — Monday-to-Sunday totals plus the top 10
apps and top 10 sites, using CSP-safe native <progress> bars.
- theme.js: light/dark from the OS preference, with a nav toggle, loaded
as an external script so the strict CSP still holds.
- cmd/control: `rollup` subcommand; rollup rebuild and audit cap at
start-up and every 15 minutes.
- Version 0.5.0-phase7 (compose, Makefile, docs).
Tests: go test ./... and go vet ./... pass in golang:1.25-alpine.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
228
internal/web/usage.go
Normal file
228
internal/web/usage.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Usage screen (AGENTS.md §5 screen 5, §11 Phase 7). It reads the daily rollup
|
||||
// and the per-app/domain detail. Read-only: telemetry only ever arrives from a
|
||||
// device, and the Control Service only stores and displays it.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
// usageDayView is one row of the week grid.
|
||||
type usageDayView struct {
|
||||
Day string // YYYY-MM-DD
|
||||
Weekday string // Mon
|
||||
Date string // 13 Oct
|
||||
IsToday bool
|
||||
AppSeconds int
|
||||
DomainSeconds int
|
||||
AppLabel string
|
||||
DomainLabel string
|
||||
// Progress is a percentage of the busiest day in the week.
|
||||
AppPercent int
|
||||
DomainPercent int
|
||||
}
|
||||
|
||||
// usageTotalView is one row of the top apps or top domains list.
|
||||
type usageTotalView struct {
|
||||
Name string
|
||||
Seconds int
|
||||
Label string
|
||||
Percent int
|
||||
}
|
||||
|
||||
type usageData struct {
|
||||
editorCommon
|
||||
WeekLabel string
|
||||
PrevWeek string
|
||||
NextWeek string
|
||||
IsThisWeek bool
|
||||
Days []usageDayView
|
||||
WeekApps int
|
||||
WeekDomains int
|
||||
WeekAppsTxt string
|
||||
WeekSiteTxt string
|
||||
MaxApp int
|
||||
MaxDomain int
|
||||
TopApps []usageTotalView
|
||||
TopDomains []usageTotalView
|
||||
HasUsage bool
|
||||
Retention string
|
||||
}
|
||||
|
||||
// formatSeconds renders a duration compactly: "9h 05m", "42m", "under a minute".
|
||||
func formatSeconds(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return "0m"
|
||||
}
|
||||
if seconds < 60 {
|
||||
return "under a minute"
|
||||
}
|
||||
m := seconds / 60
|
||||
h := m / 60
|
||||
m %= 60
|
||||
if h == 0 {
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
return fmt.Sprintf("%dh %02dm", h, m)
|
||||
}
|
||||
|
||||
// weekStart returns the Monday 00:00 of the week that contains t, in t's
|
||||
// location. The family's week is Monday to Sunday.
|
||||
func weekStart(t time.Time) time.Time {
|
||||
offset := (int(t.Weekday()) + 6) % 7 // Monday = 0
|
||||
d := t.AddDate(0, 0, -offset)
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
||||
}
|
||||
|
||||
// parseWeekAnchor reads the ?week=YYYY-MM-DD query, falling back to now. An
|
||||
// unparseable value is ignored, never an error: the screen must always render.
|
||||
func parseWeekAnchor(raw string, now time.Time) time.Time {
|
||||
if raw != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", raw, now.Location()); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceUsage(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.deviceID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dev, err := s.store.GetDevice(ctx, id)
|
||||
if err != nil {
|
||||
s.deviceLookupError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
anchor := parseWeekAnchor(r.URL.Query().Get("week"), now)
|
||||
start := weekStart(anchor)
|
||||
end := start.AddDate(0, 0, 6)
|
||||
from, to := start.Format("2006-01-02"), end.Format("2006-01-02")
|
||||
|
||||
rangeRows, err := s.store.UsageRange(ctx, id, from, to)
|
||||
if err != nil {
|
||||
s.log.Error("usage range", "device", dev.Name, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
byDay := make(map[string]store.DayUsage, len(rangeRows))
|
||||
for _, d := range rangeRows {
|
||||
byDay[d.Day] = d
|
||||
}
|
||||
|
||||
topApps, err := s.store.TopUsage(ctx, id, from, to, "app", 10)
|
||||
if err != nil {
|
||||
s.log.Error("top apps", "device", dev.Name, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
topDomains, err := s.store.TopUsage(ctx, id, from, to, "domain", 10)
|
||||
if err != nil {
|
||||
s.log.Error("top domains", "device", dev.Name, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := usageData{
|
||||
editorCommon: editorCommon{
|
||||
Title: "Usage — " + dev.Name,
|
||||
Heading: "Usage",
|
||||
Sub: fmt.Sprintf("%s · %s", dev.Name, platformLabel(dev.Platform)),
|
||||
Username: s.cfg.AdminUsername,
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
DeviceID: dev.ID,
|
||||
DeviceName: dev.Name,
|
||||
PlatformLabel: platformLabel(dev.Platform),
|
||||
},
|
||||
WeekLabel: fmt.Sprintf("%s – %s", start.Format("Mon 2 Jan"), end.Format("Mon 2 Jan 2006")),
|
||||
PrevWeek: start.AddDate(0, 0, -7).Format("2006-01-02"),
|
||||
NextWeek: start.AddDate(0, 0, 7).Format("2006-01-02"),
|
||||
IsThisWeek: start.Equal(weekStart(now)),
|
||||
Retention: "Daily rollups are kept long; the raw detail is what the dashboard sums for the top lists.",
|
||||
}
|
||||
|
||||
for i := 0; i < 7; i++ {
|
||||
day := start.AddDate(0, 0, i)
|
||||
key := day.Format("2006-01-02")
|
||||
row := byDay[key]
|
||||
v := usageDayView{
|
||||
Day: key,
|
||||
Weekday: day.Format("Mon"),
|
||||
Date: day.Format("2 Jan"),
|
||||
IsToday: key == now.Format("2006-01-02"),
|
||||
AppSeconds: row.AppSeconds,
|
||||
DomainSeconds: row.DomainSeconds,
|
||||
AppLabel: formatSeconds(row.AppSeconds),
|
||||
DomainLabel: formatSeconds(row.DomainSeconds),
|
||||
}
|
||||
data.Days = append(data.Days, v)
|
||||
data.WeekApps += row.AppSeconds
|
||||
data.WeekDomains += row.DomainSeconds
|
||||
if row.AppSeconds > data.MaxApp {
|
||||
data.MaxApp = row.AppSeconds
|
||||
}
|
||||
if row.DomainSeconds > data.MaxDomain {
|
||||
data.MaxDomain = row.DomainSeconds
|
||||
}
|
||||
}
|
||||
data.HasUsage = data.WeekApps > 0 || data.WeekDomains > 0
|
||||
data.WeekAppsTxt = formatSeconds(data.WeekApps)
|
||||
data.WeekSiteTxt = formatSeconds(data.WeekDomains)
|
||||
|
||||
// Percentages for the day bars, relative to the busiest day of the week.
|
||||
for i := range data.Days {
|
||||
data.Days[i].AppPercent = percent(data.Days[i].AppSeconds, data.MaxApp)
|
||||
data.Days[i].DomainPercent = percent(data.Days[i].DomainSeconds, data.MaxDomain)
|
||||
}
|
||||
data.TopApps = totalViews(topApps)
|
||||
data.TopDomains = totalViews(topDomains)
|
||||
|
||||
s.render(w, r, "usage.html", data)
|
||||
}
|
||||
|
||||
// percent returns value as a 0–100 integer of max. A non-positive max yields 0,
|
||||
// so an empty week renders empty bars rather than a division by zero.
|
||||
func percent(value, max int) int {
|
||||
if max <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := value * 100 / max
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// totalViews turns store totals into view rows, with each bar relative to the
|
||||
// biggest entry.
|
||||
func totalViews(totals []store.UsageTotal) []usageTotalView {
|
||||
max := 0
|
||||
for _, t := range totals {
|
||||
if t.Seconds > max {
|
||||
max = t.Seconds
|
||||
}
|
||||
}
|
||||
out := make([]usageTotalView, 0, len(totals))
|
||||
for _, t := range totals {
|
||||
out = append(out, usageTotalView{
|
||||
Name: t.Name,
|
||||
Seconds: t.Seconds,
|
||||
Label: formatSeconds(t.Seconds),
|
||||
Percent: percent(t.Seconds, max),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user