Files
internet_use_control/internal/web/usage.go
Sam Rolfe b58993d994 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>
2026-10-13 16:18:56 +11:00

229 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}