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>
229 lines
6.3 KiB
Go
229 lines
6.3 KiB
Go
// 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
|
||
}
|