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:
2026-10-13 16:18:56 +11:00
parent 9af543c394
commit b58993d994
20 changed files with 1077 additions and 20 deletions

228
internal/web/usage.go Normal file
View 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
}