diff --git a/Makefile b/Makefile index 21aa230..9c0225a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= webrestrict/control:0.4.0-phase6 -BUILD_VERSION ?= 0.4.0-phase6 +IMAGE ?= webrestrict/control:0.5.0-phase7 +BUILD_VERSION ?= 0.5.0-phase7 # Bounded docker helper for the Go toolchain. The container runs as the current # user, so the repository and the module cache stay owned by you. diff --git a/README.md b/README.md index 1bf3cfe..e9e88db 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists, per-device state and usage, and serves the web dashboard. Only it publishes device config. -This repository currently contains **Phase 6 — Schedules, blocklists, -allowlists** (AGENTS.md §11), on top of Phases 1–3: +This repository currently contains **Phase 7 — Telemetry and usage** +(AGENTS.md §11), on top of Phases 1–6: - Go service with its own image (`Dockerfile`) and compose file. - SQLite schema for the whole data model (§7), applied through versioned @@ -34,10 +34,14 @@ allowlists** (AGENTS.md §11), on top of Phases 1–3: process) and `site` (hostname) entries. While a device is blocked, the device blocks every blocklist value **except** the allowlist values (Spotify at night). That allow decision is in the published config. +- **Usage**: telemetry reports are stored per device, day, app and domain and + rolled up into a daily summary. The dashboard shows a week at a time — the + daily total plus the top apps and top sites — with dark mode and phone-sized + touch targets. - Every edit is written to the append-only audit log (FR-15). -Enforcement itself (the kids' clients), the admin API, usage screens and the Hub -arrive in later phases. Nothing here touches the router, Pi-hole, or DNS. +Enforcement itself (the kids' clients), the admin API and the Hub arrive in +later phases. Nothing here touches the router, Pi-hole, or DNS. ## Quick start (on `.13`) @@ -53,7 +57,7 @@ docker compose up -d --build # 3. Prove it. curl -s http://127.0.0.1:8791/health -# {"status":"ok","service":"control-service","version":"0.4.0-phase6",...} +# {"status":"ok","service":"control-service","version":"0.5.0-phase7",...} ``` Open in a browser. It redirects to `/login`. Sign in @@ -90,7 +94,7 @@ reads only the process environment. Generate a bcrypt hash without host Go: ```bash -docker run --rm --entrypoint /app/control webrestrict/control:0.4.0-phase6 \ +docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 \ hash-password 'your-password' ``` @@ -172,7 +176,7 @@ List the MQTT identity of every device (tokens, no secrets): docker exec webrestrict-control /app/control mqtt-clients ``` -## Endpoints (Phase 2–3) +## Endpoints (Phase 2–7) | Method | Path | Notes | |---|---|---| @@ -193,11 +197,46 @@ docker exec webrestrict-control /app/control mqtt-clients | `GET` | `/devices/{id}/lists` | Blocklist / allowlist editor. | | `POST` | `/devices/{id}/lists` | Adds one entry (`table=block|allow`, `kind=app|site`, `value`). | | `POST` | `/devices/{id}/lists/{entryID}/delete` | Removes one entry. | +| `GET` | `/devices/{id}/usage` | Usage for one device: a week of daily totals, top apps and top sites. `?week=YYYY-MM-DD` selects the week (any day in it; default today). | Every form POST needs the `csrf` field from the session cookie. State changes append to the `audit` table and republish the device's retained config. Devices are never hard-deleted (§10 rule 10). +## Telemetry and usage (Phase 7) + +Usage arrives on `restrict//telemetry` and is stored in `telemetry`: +one row per (device, day, app, domain), where a repeat report for the same key +replaces the earlier total (a rolling daily total, not a delta). + +`telemetry_daily` is the **daily rollup** — one row per (device, day) — written +together with the detail row and rebuildable from it with `control rollup`. +Apps and domains are summed into separate columns on purpose: a report may +carry both for the same session, so a single total would double-count the time. +The rollup is derived data, never a second source of truth. + +`GET /devices/{id}/usage` shows one Monday-to-Sunday week: the app total and the +site total per day, then the top 10 apps and top 10 sites for the week. The +screen uses the Family Home Lab tokens, follows the OS light/dark preference +(there is a toggle in the nav bar), and works on a phone browser. + +Retention: + +- The daily rollup is kept long; the detail table is small and kept too. + Nothing is hard-deleted (AGENTS.md §10 rule 10). +- The append-only `audit` log is **size-capped** at 20 000 rows by the service's + maintenance loop, oldest first (§7). It is never edited in place. + +Rebuild the rollup from the detail table at any time (idempotent, safe while the +service is up; the service also does it at start-up and every 15 minutes): + +```bash +# inside the container +docker exec webrestrict-control /app/control rollup +# or against a database file on the host +/app/control rollup --db /data/control.db +``` + ## Schedules, blocklists and allowlists (Phase 6) A **schedule** is a set of weekly blocking windows per device: a weekday @@ -239,14 +278,14 @@ broker on `127.0.0.1:18883` and removes it afterwards. ## Layout ``` -cmd/control/ service entry point (+ hash-password, mqtt-clients) +cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup) internal/config/ environment configuration internal/store/ SQLite open, migrations, audit, devices + state devices.go device registry, desired state, lock expiry schedules.go weekly windows and the effective state - reports.go presence and telemetry written from broker reports + reports.go presence, telemetry, the daily rollup, top lists, audit cap lists.go allow/block lists and device lookup by topic - migrations/001_init.sql, 002_telemetry_unique.sql + migrations/001_init.sql, 002_telemetry_unique.sql, 003_usage_rollup.sql internal/auth/ signed-cookie session login + CSRF internal/mqtt/ the broker link: retained config out, reports in payload.go topic and payload contract for the kids' clients @@ -254,8 +293,9 @@ internal/mqtt/ the broker link: retained config out, reports in internal/web/ HTTP handlers, templates, static assets devices.go device registry + manual lock/unlock handlers schedules.go weekly schedule and block/allow editors - templates/ login, dashboard, device form, editors, HTMX partials - static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts + usage.go usage screen: week grid, top apps, top sites + templates/ login, dashboard, device form, editors, usage, partials + static/ tokens.css (Family Home Lab), app.css, theme.js, htmx, fonts Dockerfile own image (multi-stage, CGO-free) docker-compose.yml own container, port 8791 environment.d/ control-service.env.example (real file gitignored) @@ -269,12 +309,14 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier `webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35` (sam-Ubuntu1); see that file for the two proxy patterns and the apply commands. -## Non-goals (Phase 6) +## Non-goals (Phase 7) - No group/global list editing from the UI. The data model supports a NULL `device_id` that applies to every device, and the effective state merges it, but the editor manages per-device rows only. -- No usage screens (Phase 7). Telemetry is stored, not yet shown. +- No HTTP telemetry ingest. Devices report over MQTT; an HTTP path arrives if a + client needs it. +- No usage charts or export beyond the week grid and the top lists shown. - No admin API (Phase 8). - No kids' clients (Phases 4–5). The topic and payload contract above is what they implement. The clients apply the allowlist exception; the Control Service diff --git a/cmd/control/main.go b/cmd/control/main.go index 62cc8c9..d14fbf3 100644 --- a/cmd/control/main.go +++ b/cmd/control/main.go @@ -41,6 +41,14 @@ const republishInterval = 15 * time.Minute // the loop last looked. const republishPoll = 30 * time.Second +// Maintenance (Phase 7): the daily usage rollup is rebuilt periodically so it +// is repaired from the detail table, and the append-only audit log is capped so +// it cannot grow without bound (AGENTS.md §7 retention). +const ( + maintenanceInterval = 15 * time.Minute + auditMaxRows = 20000 +) + func main() { if len(os.Args) > 1 { switch os.Args[1] { @@ -50,6 +58,9 @@ func main() { case "mqtt-clients": mqttClients(os.Args[2:]) return + case "rollup": + rollup(os.Args[2:]) + return case "version": fmt.Println(version) return @@ -83,6 +94,19 @@ func run() error { return err } + // Backfill the daily rollup from any telemetry written before Phase 7, then + // cap the audit log. Both are idempotent. + if n, err := st.RebuildRollups(context.Background()); err != nil { + return err + } else if n > 0 { + log.Info("usage rollups rebuilt", "rows", n) + } + if n, err := st.CapAudit(context.Background(), auditMaxRows); err != nil { + log.Error("audit cap", "err", err) + } else if n > 0 { + log.Info("audit log capped", "removed", n, "max", auditMaxRows) + } + am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure, cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword) if err != nil { @@ -95,6 +119,8 @@ func run() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go maintenanceLoop(ctx, log, st) + // The broker link (Phase 3). It is optional on purpose: the dashboard must // keep working while the broker is down, and the device keeps its last state // (AGENTS.md §10 rule 2). @@ -234,6 +260,66 @@ func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *s } } +// maintenanceLoop keeps derived data healthy without blocking the service: it +// rebuilds the daily rollups and caps the audit log on a fixed interval. +func maintenanceLoop(ctx context.Context, log *slog.Logger, st *store.Store) { + ticker := time.NewTicker(maintenanceInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runMaintenance(ctx, log, st) + } + } +} + +func runMaintenance(ctx context.Context, log *slog.Logger, st *store.Store) { + mctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if n, err := st.RebuildRollups(mctx); err != nil { + log.Error("usage rollups", "err", err) + } else if n > 0 { + log.Debug("usage rollups rebuilt", "rows", n) + } + if n, err := st.CapAudit(mctx, auditMaxRows); err != nil { + log.Error("audit cap", "err", err) + } else if n > 0 { + log.Info("audit log capped", "removed", n, "max", auditMaxRows) + } +} + +// rollup rebuilds the daily usage rollup from the telemetry detail table. It is +// idempotent and safe to run while the service is up; it exits non-zero on a +// failure so a script can tell. +func rollup(args []string) { + dbPath := env("CONTROL_DB_PATH", "/data/control.db") + for i := 0; i < len(args); i++ { + if (args[i] == "--db" || args[i] == "-db") && i+1 < len(args) { + dbPath = args[i+1] + i++ + } + } + + st, err := store.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + defer st.Close() + if err := st.Migrate(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + n, err := st.RebuildRollups(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, "control:", err) + os.Exit(1) + } + fmt.Printf("rebuilt %d daily rollup rows from %s\n", n, dbPath) +} + // mqttClients prints the MQTT identity of every registered device, one device // per line, as tab-separated fields: // diff --git a/docker-compose.yml b/docker-compose.yml index fb946cf..b6c1a9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: build: context: . args: - VERSION: "0.4.0-phase6" - image: webrestrict/control:0.4.0-phase6 + VERSION: "0.5.0-phase7" + image: webrestrict/control:0.5.0-phase7 container_name: webrestrict-control restart: unless-stopped env_file: diff --git a/environment.d/control-service.env.example b/environment.d/control-service.env.example index d0a228c..b919db3 100644 --- a/environment.d/control-service.env.example +++ b/environment.d/control-service.env.example @@ -38,7 +38,7 @@ CONTROL_ADMIN_USERNAME=sam CONTROL_ADMIN_PASSWORD=change-me # Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty. -# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.3.0-phase3 hash-password 'your-password' +# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 hash-password 'your-password' # CONTROL_ADMIN_PASSWORD_HASH=$2a$10$... # --- MQTT (Phase 3) -------------------------------------------------------- diff --git a/internal/store/migrations/003_usage_rollup.sql b/internal/store/migrations/003_usage_rollup.sql new file mode 100644 index 0000000..bb41489 --- /dev/null +++ b/internal/store/migrations/003_usage_rollup.sql @@ -0,0 +1,24 @@ +-- Migration 003 — Phase 7. Daily usage rollup (AGENTS.md §7 retention). +-- +-- telemetry holds the per-day detail: one row per (device, day, app, domain). +-- This table is the daily rollup the dashboard reads for a week and that is +-- kept long. Apps and domains are summed into separate columns on purpose: a +-- report can carry both for the same session, so adding them together would +-- double-count the time. +-- +-- The rollup is written whenever a telemetry report lands and can be rebuilt +-- from telemetry at any time (store.RebuildRollups), so it is derived data, +-- never a second source of truth. + +CREATE TABLE IF NOT EXISTS telemetry_daily ( + device_id INTEGER NOT NULL REFERENCES devices(id), + day TEXT NOT NULL, -- YYYY-MM-DD, local day + app_seconds INTEGER NOT NULL DEFAULT 0, + domain_seconds INTEGER NOT NULL DEFAULT 0, + app_count INTEGER NOT NULL DEFAULT 0, + domain_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (device_id, day) +); + +CREATE INDEX IF NOT EXISTS idx_telemetry_daily_day ON telemetry_daily(day); diff --git a/internal/store/reports.go b/internal/store/reports.go index 64fed1e..edb59b3 100644 --- a/internal/store/reports.go +++ b/internal/store/reports.go @@ -9,6 +9,7 @@ package store import ( "context" + "database/sql" "fmt" "time" ) @@ -70,7 +71,8 @@ type TelemetryEntry struct { // InsertTelemetry upserts a rolling daily usage total per (device, day, app, // domain). Reports are repeats, not deltas: a later report for the same key -// replaces the earlier total (migration 002). It returns the number of rows +// replaces the earlier total (migration 002). The affected days are rolled up +// in the same transaction (migration 003). It returns the number of rows // written. func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entries []TelemetryEntry) (int, error) { if len(entries) == 0 { @@ -84,6 +86,7 @@ func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entri defer func() { _ = tx.Rollback() }() n := 0 + days := make(map[string]struct{}, 1) for _, e := range entries { if e.Seconds < 0 { continue @@ -97,14 +100,187 @@ func (s *Store) InsertTelemetry(ctx context.Context, id int64, day string, entri id, e.App, e.Domain, e.Seconds, day); err != nil { return n, fmt.Errorf("insert telemetry: %w", err) } + days[day] = struct{}{} n++ } + for d := range days { + if err := rollupDay(ctx, tx, id, d); err != nil { + return n, err + } + } if err := tx.Commit(); err != nil { return n, fmt.Errorf("commit telemetry: %w", err) } return n, nil } +// rollupDay recomputes one device/day row of telemetry_daily from the detail +// table. Apps and domains are summed separately so a report that carries both +// is not double-counted. +func rollupDay(ctx context.Context, ex interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}, id int64, day string) error { + if _, err := ex.ExecContext(ctx, ` + INSERT INTO telemetry_daily + (device_id, day, app_seconds, domain_seconds, app_count, domain_count, updated_at) + SELECT ?, ?, + COALESCE(SUM(CASE WHEN app <> '' THEN seconds END), 0), + COALESCE(SUM(CASE WHEN domain <> '' THEN seconds END), 0), + COALESCE(SUM(CASE WHEN app <> '' THEN 1 END), 0), + COALESCE(SUM(CASE WHEN domain <> '' THEN 1 END), 0), + datetime('now') + FROM telemetry WHERE device_id = ? AND day = ? + ON CONFLICT(device_id, day) DO UPDATE SET + app_seconds = excluded.app_seconds, + domain_seconds = excluded.domain_seconds, + app_count = excluded.app_count, + domain_count = excluded.domain_count, + updated_at = datetime('now')`, + id, day, id, day); err != nil { + return fmt.Errorf("rollup day: %w", err) + } + return nil +} + +// RebuildRollups recomputes every telemetry_daily row from the detail table. It +// is idempotent and is how the rollup is backfilled after an upgrade or +// repaired after an import. It returns the number of rows inserted or updated. +func (s *Store) RebuildRollups(ctx context.Context) (int, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO telemetry_daily + (device_id, day, app_seconds, domain_seconds, app_count, domain_count, updated_at) + SELECT device_id, day, + COALESCE(SUM(CASE WHEN app <> '' THEN seconds END), 0), + COALESCE(SUM(CASE WHEN domain <> '' THEN seconds END), 0), + COALESCE(SUM(CASE WHEN app <> '' THEN 1 END), 0), + COALESCE(SUM(CASE WHEN domain <> '' THEN 1 END), 0), + datetime('now') + FROM telemetry + GROUP BY device_id, day + ON CONFLICT(device_id, day) DO UPDATE SET + app_seconds = excluded.app_seconds, + domain_seconds = excluded.domain_seconds, + app_count = excluded.app_count, + domain_count = excluded.domain_count, + updated_at = datetime('now')`) + if err != nil { + return 0, fmt.Errorf("rebuild rollups: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("rebuild rollups: %w", err) + } + return int(n), nil +} + +// DayUsage is one day of rolled-up usage for a device. AppSeconds and +// DomainSeconds are kept apart because a report may carry both. +type DayUsage struct { + Day string + AppSeconds int + DomainSeconds int + AppCount int + DomainCount int +} + +// UsageRange returns the daily rollup for a device in [from, to] inclusive, +// oldest first. Days without a report are absent; the caller fills the gaps. +func (s *Store) UsageRange(ctx context.Context, id int64, from, to string) ([]DayUsage, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT day, app_seconds, domain_seconds, app_count, domain_count + FROM telemetry_daily + WHERE device_id = ? AND day BETWEEN ? AND ? + ORDER BY day`, id, from, to) + if err != nil { + return nil, fmt.Errorf("usage range: %w", err) + } + defer rows.Close() + + out := make([]DayUsage, 0, 8) + for rows.Next() { + var d DayUsage + if err := rows.Scan(&d.Day, &d.AppSeconds, &d.DomainSeconds, &d.AppCount, &d.DomainCount); err != nil { + return nil, fmt.Errorf("scan usage day: %w", err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("usage range: %w", err) + } + return out, nil +} + +// UsageTotal is one app or domain total over a range. +type UsageTotal struct { + Name string + Seconds int +} + +// TopUsage returns the biggest apps or domains for a device in [from, to] +// inclusive. kind is "app" or "domain"; anything else returns no rows. The +// column is chosen by a switch, never interpolated, so the argument can never +// reach SQL as text. +func (s *Store) TopUsage(ctx context.Context, id int64, from, to, kind string, limit int) ([]UsageTotal, error) { + column := "" + switch kind { + case "app": + column = "app" + case "domain": + column = "domain" + default: + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 10 + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT `+column+`, SUM(seconds) + FROM telemetry + WHERE device_id = ? AND day BETWEEN ? AND ? AND `+column+` <> '' + GROUP BY `+column+` + ORDER BY SUM(seconds) DESC, `+column+` ASC + LIMIT ?`, id, from, to, limit) + if err != nil { + return nil, fmt.Errorf("top %s: %w", column, err) + } + defer rows.Close() + + out := make([]UsageTotal, 0, limit) + for rows.Next() { + var t UsageTotal + if err := rows.Scan(&t.Name, &t.Seconds); err != nil { + return nil, fmt.Errorf("scan top %s: %w", column, err) + } + out = append(out, t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("top %s: %w", column, err) + } + return out, nil +} + +// CapAudit keeps the newest max audit rows and deletes the oldest beyond that, +// so the append-only log cannot grow without bound (AGENTS.md §7 retention). It +// returns how many rows it removed. Nothing in the audit log is ever edited. +func (s *Store) CapAudit(ctx context.Context, max int) (int, error) { + if max <= 0 { + return 0, nil + } + res, err := s.db.ExecContext(ctx, ` + DELETE FROM audit WHERE id NOT IN ( + SELECT id FROM audit ORDER BY id DESC LIMIT ? + )`, max) + if err != nil { + return 0, fmt.Errorf("cap audit: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("cap audit: %w", err) + } + return int(n), nil +} + // TelemetryForDay returns one device's usage rows for a day, biggest first. func (s *Store) TelemetryForDay(ctx context.Context, id int64, day string) ([]TelemetryEntry, error) { rows, err := s.db.QueryContext(ctx, ` diff --git a/internal/store/reports_test.go b/internal/store/reports_test.go index 55efd6a..5792f76 100644 --- a/internal/store/reports_test.go +++ b/internal/store/reports_test.go @@ -185,3 +185,137 @@ func TestDeviceListsAndTopicLookup(t *testing.T) { t.Errorf("unknown topic error = %v, want ErrNotFound", err) } } + +func TestTelemetryRollupAndWeekQueries(t *testing.T) { + st, dev := newTestDevice(t) + ctx := context.Background() + + // Three days of usage. Apps and domains are reported for the same day on + // purpose, to prove the rollup keeps them apart instead of double-counting. + days := []struct { + day string + app string + appSecs int + domain string + webSecs int + }{ + {"2026-10-12", "com.spotify.music", 600, "youtube.com", 120}, + {"2026-10-13", "com.spotify.music", 900, "youtube.com", 300}, + {"2026-10-13", "com.roblox.client", 1800, "roblox.com", 1800}, + {"2026-10-14", "com.roblox.client", 3600, "roblox.com", 3600}, + } + for _, d := range days { + if _, err := st.InsertTelemetry(ctx, dev.ID, d.day, []TelemetryEntry{ + {App: d.app, Seconds: d.appSecs}, + {Domain: d.domain, Seconds: d.webSecs}, + }); err != nil { + t.Fatalf("InsertTelemetry %s: %v", d.day, err) + } + } + + usage, err := st.UsageRange(ctx, dev.ID, "2026-10-12", "2026-10-14") + if err != nil { + t.Fatalf("UsageRange: %v", err) + } + if len(usage) != 3 { + t.Fatalf("usage rows = %d, want 3", len(usage)) + } + if usage[0].Day != "2026-10-12" || usage[0].AppSeconds != 600 || usage[0].DomainSeconds != 120 { + t.Errorf("day 1 = %+v", usage[0]) + } + if usage[1].AppSeconds != 2700 || usage[1].DomainSeconds != 2100 { + t.Errorf("day 2 = %+v, want apps 2700 and domains 2100 (no cross-counting)", usage[1]) + } + if usage[2].AppSeconds != 3600 || usage[2].DomainSeconds != 3600 { + t.Errorf("day 3 = %+v", usage[2]) + } + + // A repeated report replaces the day's total, and the rollup follows. + if _, err := st.InsertTelemetry(ctx, dev.ID, "2026-10-13", []TelemetryEntry{ + {App: "com.roblox.client", Seconds: 200}, + }); err != nil { + t.Fatalf("InsertTelemetry replace: %v", err) + } + usage, _ = st.UsageRange(ctx, dev.ID, "2026-10-13", "2026-10-13") + if len(usage) != 1 || usage[0].AppSeconds != 1100 { + t.Errorf("day 2 after replace = %+v, want apps 1100 (spotify 900 + roblox 200)", usage) + } + + // Top lists are ordered by total seconds. + topApps, err := st.TopUsage(ctx, dev.ID, "2026-10-12", "2026-10-14", "app", 10) + if err != nil { + t.Fatalf("TopUsage app: %v", err) + } + if len(topApps) != 2 || topApps[0].Name != "com.roblox.client" || topApps[0].Seconds != 3800 { + t.Errorf("top apps = %+v, want roblox first with 3800", topApps) + } + topDomains, err := st.TopUsage(ctx, dev.ID, "2026-10-12", "2026-10-14", "domain", 10) + if err != nil { + t.Fatalf("TopUsage domain: %v", err) + } + if len(topDomains) != 2 || topDomains[0].Name != "roblox.com" || topDomains[0].Seconds != 5400 { + t.Errorf("top domains = %+v", topDomains) + } + + // An unknown kind and a range with no data are both empty, not errors. + if got, err := st.TopUsage(ctx, dev.ID, "2026-10-12", "2026-10-14", "nonsense", 10); err != nil || len(got) != 0 { + t.Errorf("TopUsage nonsense = (%+v, %v), want empty", got, err) + } + if got, err := st.UsageRange(ctx, dev.ID, "2020-01-01", "2020-01-07"); err != nil || len(got) != 0 { + t.Errorf("UsageRange empty = (%+v, %v), want empty", got, err) + } +} + +func TestRebuildRollupsAndCapAudit(t *testing.T) { + st, dev := newTestDevice(t) + ctx := context.Background() + + // Simulate pre-Phase-7 data: telemetry written before the rollup existed. + if _, err := st.DB().ExecContext(ctx, + `INSERT INTO telemetry (device_id, app, domain, seconds, day) VALUES (?, '', 'example.com', 300, '2026-10-13')`, + dev.ID); err != nil { + t.Fatalf("seed telemetry: %v", err) + } + var count int + if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM telemetry_daily`).Scan(&count); err != nil { + t.Fatalf("count rollups: %v", err) + } + if count != 0 { + t.Fatalf("telemetry_daily has %d rows before rebuild, want 0", count) + } + n, err := st.RebuildRollups(ctx) + if err != nil { + t.Fatalf("RebuildRollups: %v", err) + } + if n != 1 { + t.Errorf("RebuildRollups = %d rows, want 1", n) + } + usage, err := st.UsageRange(ctx, dev.ID, "2026-10-13", "2026-10-13") + if err != nil || len(usage) != 1 || usage[0].DomainSeconds != 300 { + t.Fatalf("usage after rebuild = (%+v, %v)", usage, err) + } + + // The audit cap keeps the newest rows and never edits the rest. + for i := 0; i < 25; i++ { + if err := st.Audit(ctx, "sam", "test", nil, "row"); err != nil { + t.Fatalf("Audit: %v", err) + } + } + removed, err := st.CapAudit(ctx, 10) + if err != nil { + t.Fatalf("CapAudit: %v", err) + } + if removed == 0 { + t.Error("CapAudit removed nothing, want the oldest rows gone") + } + if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&count); err != nil { + t.Fatalf("count audit: %v", err) + } + if count != 10 { + t.Errorf("audit rows = %d, want 10", count) + } + // A no-op cap leaves the log alone. + if n, err := st.CapAudit(ctx, 100); err != nil || n != 0 { + t.Errorf("CapAudit(100) = (%d, %v), want (0, nil)", n, err) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 214ee80..ea3ba09 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -101,6 +101,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /devices/{id}/lists", s.requireAuth(s.handleListsEditor)) mux.HandleFunc("POST /devices/{id}/lists", s.requireAuth(s.handleListAdd)) mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete)) + mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage)) staticSub, err := fs.Sub(staticFS, "static") if err != nil { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 17fdf80..9143450 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -546,6 +546,65 @@ a.button-utility { text-decoration: none; } .list-entry__value { flex: 1 1 auto; word-break: break-all; } .list-entry__remove { margin: 0; } +/* ---------- Usage (Phase 7) ---------- */ + +.usage-table { + width: 100%; + border-collapse: collapse; + min-width: 520px; +} +.usage-table th, +.usage-table td { + text-align: left; + padding: var(--space-xs) var(--space-sm); + border-bottom: 1px solid var(--hairline); + vertical-align: middle; +} +.usage-table thead th { + font: var(--type-eyebrow); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); +} +.usage-table tbody th { font: var(--type-body-md); color: var(--ink); white-space: nowrap; } +.usage-table__date { font: var(--type-caption); color: var(--ink-muted); } +.usage-table__num { font: var(--type-body-md); color: var(--ink-secondary); white-space: nowrap; } +.usage-table__bar { width: 40%; min-width: 120px; } +.usage-table__today th { color: var(--primary); } + +/* Native bars: no inline styles, so the strict + Content-Security-Policy (style-src 'self') keeps holding. */ +.usage-bar { + display: block; + width: 100%; + height: 10px; + border: 0; + border-radius: var(--rounded-full); + overflow: hidden; + background: var(--surface-2); + appearance: none; + -webkit-appearance: none; +} +.usage-bar::-webkit-progress-bar { background: var(--surface-2); border-radius: var(--rounded-full); } +.usage-bar::-webkit-progress-value { background: var(--primary); border-radius: var(--rounded-full); } +.usage-bar::-moz-progress-bar { background: var(--primary); border-radius: var(--rounded-full); } +.usage-bar--full { margin-top: var(--space-xxs); } + +.usage-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-sm); } +.usage-item { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-xxs) var(--space-sm); + align-items: center; + padding: var(--space-xs) 0; + border-top: 1px solid var(--hairline); +} +.usage-item:first-child { border-top: 0; } +.usage-item--empty { color: var(--ink-muted); font-style: italic; } +.usage-item__name { font: var(--type-body-md); color: var(--ink); overflow-wrap: anywhere; } +.usage-item__time { font: var(--type-body-sm); color: var(--ink-muted); white-space: nowrap; } +.usage-item .usage-bar { grid-column: 1 / -1; } + /* ---------- Responsive ---------- */ @media (max-width: 760px) { diff --git a/internal/web/static/theme.js b/internal/web/static/theme.js new file mode 100644 index 0000000..93efc6f --- /dev/null +++ b/internal/web/static/theme.js @@ -0,0 +1,55 @@ +// Theme controller. The design tokens ship both palettes; nothing switches +// between them unless this runs. It follows the OS preference by default and +// remembers an explicit choice. Loaded with defer from /static/, so it is +// allowed by the page's `script-src 'self'` policy (no inline script). +(function () { + "use strict"; + var KEY = "webrestrict.theme"; + var root = document.documentElement; + var media = window.matchMedia("(prefers-color-scheme: dark)"); + var stored = null; + try { + stored = window.localStorage.getItem(KEY); + } catch (e) { + stored = null; + } + + function apply() { + var theme = stored || (media.matches ? "dark" : "light"); + root.setAttribute("data-theme", theme); + var buttons = document.querySelectorAll("[data-theme-toggle]"); + for (var i = 0; i < buttons.length; i++) { + buttons[i].textContent = theme === "dark" ? "Light" : "Dark"; + buttons[i].setAttribute( + "aria-label", + "Switch to " + (theme === "dark" ? "light" : "dark") + " theme" + ); + } + } + + document.addEventListener("click", function (event) { + var target = event.target && event.target.closest + ? event.target.closest("[data-theme-toggle]") + : null; + if (!target) { + return; + } + stored = root.getAttribute("data-theme") === "dark" ? "light" : "dark"; + try { + window.localStorage.setItem(KEY, stored); + } catch (e) { + /* private mode: the choice just does not persist */ + } + apply(); + }); + + if (media.addEventListener) { + media.addEventListener("change", function () { + if (!stored) { + apply(); + } + }); + } + + apply(); +})(); diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 0c93c1f..077bac1 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -8,6 +8,7 @@ +