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:
4
Makefile
4
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.
|
||||
|
||||
70
README.md
70
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 <http://127.0.0.1:8791/> 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/<device>/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
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) --------------------------------------------------------
|
||||
|
||||
24
internal/store/migrations/003_usage_rollup.sql
Normal file
24
internal/store/migrations/003_usage_rollup.sql
Normal file
@@ -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);
|
||||
@@ -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, `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <progress> 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) {
|
||||
|
||||
55
internal/web/static/theme.js
Normal file
55
internal/web/static/theme.js
Normal file
@@ -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();
|
||||
})();
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
@@ -16,6 +17,7 @@
|
||||
Internet Use Control
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
<form method="post" action="/logout" class="nav-bar__logout">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<title>{{.Title}} — Internet Use Control</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
@@ -15,6 +16,7 @@
|
||||
Internet Use Control
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<title>{{.Title}} — Internet Use Control</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
@@ -15,6 +16,7 @@
|
||||
Internet Use Control
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<title>Sign in — Internet Use Control</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<main class="login-band">
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a class="button-utility" href="/devices/{{.ID}}/edit">Edit</a>
|
||||
<a class="button-utility" href="/devices/{{.ID}}/schedule">Schedule</a>
|
||||
<a class="button-utility" href="/devices/{{.ID}}/lists">Lists</a>
|
||||
<a class="button-utility" href="/devices/{{.ID}}/usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<p class="device-card__foot">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<title>{{.Title}} — Internet Use Control</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
@@ -15,6 +16,7 @@
|
||||
Internet Use Control
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
148
internal/web/templates/usage.html
Normal file
148
internal/web/templates/usage.html
Normal file
@@ -0,0 +1,148 @@
|
||||
{{define "usage.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{.Title}} — Internet Use Control</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/theme.js" defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<nav class="nav-bar">
|
||||
<a class="nav-bar__brand" href="/">
|
||||
<span class="nav-bar__mark" aria-hidden="true"></span>
|
||||
Internet Use Control
|
||||
</a>
|
||||
<div class="nav-bar__right">
|
||||
<span class="nav-bar__user">{{.Username}}</span>
|
||||
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
<header class="page-head">
|
||||
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
|
||||
<h1 class="heading-1">Usage</h1>
|
||||
<p class="page-head__sub">{{.Sub}}</p>
|
||||
</header>
|
||||
|
||||
{{if .Flash}}<p class="flash flash--ok" role="status">{{.Flash}}</p>{{end}}
|
||||
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
|
||||
|
||||
<section class="section" aria-labelledby="week-title">
|
||||
<div class="section__head">
|
||||
<h2 id="week-title" class="heading-2">Week of {{.WeekLabel}}</h2>
|
||||
<div class="section__actions">
|
||||
<a class="button-utility" href="/devices/{{.DeviceID}}/usage?week={{.PrevWeek}}">← Previous</a>
|
||||
{{if not .IsThisWeek}}<a class="button-utility" href="/devices/{{.DeviceID}}/usage">This week</a>{{end}}
|
||||
<a class="button-utility" href="/devices/{{.DeviceID}}/usage?week={{.NextWeek}}">Next →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<article class="tool-card">
|
||||
<div class="tool-card__icon tool-card__icon--sky" aria-hidden="true">▤</div>
|
||||
<h3 class="tool-card__title">App time</h3>
|
||||
<p class="tool-card__desc">{{.WeekAppsTxt}} this week</p>
|
||||
<span class="status-pill status-pill--muted">screen time</span>
|
||||
</article>
|
||||
<article class="tool-card">
|
||||
<div class="tool-card__icon tool-card__icon--purple" aria-hidden="true">⇄</div>
|
||||
<h3 class="tool-card__title">Site time</h3>
|
||||
<p class="tool-card__desc">{{.WeekSiteTxt}} this week</p>
|
||||
<span class="status-pill status-pill--muted">by hostname</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{{if .HasUsage}}
|
||||
<div class="schedule-scroll">
|
||||
<table class="usage-table">
|
||||
<caption class="visually-hidden">Daily usage for the selected week</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Day</th>
|
||||
<th scope="col">Apps</th>
|
||||
<th scope="col">Sites</th>
|
||||
<th scope="col">Screen time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Days}}
|
||||
<tr{{if .IsToday}} class="usage-table__today"{{end}}>
|
||||
<th scope="row">{{.Weekday}} <span class="usage-table__date">{{.Date}}{{if .IsToday}} · today{{end}}</span></th>
|
||||
<td class="usage-table__num">{{.AppLabel}}</td>
|
||||
<td class="usage-table__num">{{.DomainLabel}}</td>
|
||||
<td class="usage-table__bar">
|
||||
<progress class="usage-bar" value="{{.AppSeconds}}" max="{{$.MaxApp}}"
|
||||
aria-label="App time {{.AppLabel}} on {{.Date}}"></progress>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p class="empty-state__title">No usage for this week</p>
|
||||
<p class="empty-state__body">No device has reported telemetry for this week. Reports arrive on
|
||||
<code>restrict/<device>/telemetry</code> and are rolled up per day.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .HasUsage}}
|
||||
<div class="list-grid">
|
||||
<section class="card-panel list-panel" aria-labelledby="top-apps-title">
|
||||
<h2 id="top-apps-title" class="heading-2">Top apps</h2>
|
||||
<p class="list-panel__sub">Total time this week, biggest first.</p>
|
||||
<ul class="usage-list">
|
||||
{{range .TopApps}}
|
||||
<li class="usage-item">
|
||||
<span class="usage-item__name" title="{{.Name}}">{{.Name}}</span>
|
||||
<span class="usage-item__time">{{.Label}}</span>
|
||||
<progress class="usage-bar usage-bar--full" value="{{.Percent}}" max="100"
|
||||
aria-label="{{.Name}}: {{.Label}}"></progress>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="usage-item usage-item--empty">No apps reported.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card-panel list-panel" aria-labelledby="top-domains-title">
|
||||
<h2 id="top-domains-title" class="heading-2">Top sites</h2>
|
||||
<p class="list-panel__sub">Total time this week, biggest first.</p>
|
||||
<ul class="usage-list">
|
||||
{{range .TopDomains}}
|
||||
<li class="usage-item">
|
||||
<span class="usage-item__name" title="{{.Name}}">{{.Name}}</span>
|
||||
<span class="usage-item__time">{{.Label}}</span>
|
||||
<progress class="usage-bar usage-bar--full" value="{{.Percent}}" max="100"
|
||||
aria-label="{{.Name}}: {{.Label}}"></progress>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="usage-item usage-item--empty">No sites reported.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="schedule-note__hint">{{.Retention}}</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="button-utility" href="/devices/{{.DeviceID}}/schedule">Weekly schedule</a>
|
||||
<a class="button-utility" href="/devices/{{.DeviceID}}/lists">Blocklist and allowlist</a>
|
||||
<a class="button-utility" href="/">Done</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-foot">
|
||||
<span>Control Service · {{.Version}} · {{.AppEnv}}</span>
|
||||
<span class="app-foot__sep">·</span>
|
||||
<span>{{year}} Family Home Lab</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
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
|
||||
}
|
||||
94
internal/web/usage_test.go
Normal file
94
internal/web/usage_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
func TestUsageScreenShowsAWeek(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
dev := createTestDevice(t, app)
|
||||
ctx := t.Context()
|
||||
|
||||
// Seed the whole current week: apps and domains for every day.
|
||||
start := weekStart(time.Now())
|
||||
for i := 0; i < 7; i++ {
|
||||
day := start.AddDate(0, 0, i).Format("2006-01-02")
|
||||
if _, err := app.store.InsertTelemetry(ctx, dev.ID, day, []store.TelemetryEntry{
|
||||
{App: "com.roblox.client", Seconds: (i + 1) * 600},
|
||||
{App: "com.spotify.music", Seconds: 300},
|
||||
{Domain: "roblox.com", Seconds: (i + 1) * 600},
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertTelemetry %s: %v", day, err)
|
||||
}
|
||||
}
|
||||
|
||||
rec := app.do(t, http.MethodGet, fmt.Sprintf("/devices/%d/usage", dev.ID), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET usage = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Usage", "Top apps", "Top sites", "App time", "Site time",
|
||||
"com.roblox.client", "com.spotify.music", "roblox.com",
|
||||
fmt.Sprintf("%d", dev.ID), "Harry's Pixel",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("usage page missing %q", want)
|
||||
}
|
||||
}
|
||||
// Seven day rows: the weekday abbreviations must all be present.
|
||||
for _, wd := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
|
||||
if !strings.Contains(body, ">"+wd+" <") {
|
||||
t.Errorf("usage page missing the %s row", wd)
|
||||
}
|
||||
}
|
||||
|
||||
// A week with no data renders the empty state, never an error.
|
||||
rec = app.do(t, http.MethodGet,
|
||||
fmt.Sprintf("/devices/%d/usage?week=2020-01-01", dev.ID), nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "No usage for this week") {
|
||||
t.Errorf("empty week = %d, want the empty state", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekHelpers(t *testing.T) {
|
||||
loc := time.UTC
|
||||
// A Sunday belongs to the week that started the previous Monday.
|
||||
sunday := time.Date(2026, 10, 18, 12, 0, 0, 0, loc) // Sunday
|
||||
got := weekStart(sunday)
|
||||
want := time.Date(2026, 10, 12, 0, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("weekStart(Sun) = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
// A bad ?week= value falls back to now instead of failing.
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
|
||||
if got := parseWeekAnchor("not-a-date", now); !got.Equal(now) {
|
||||
t.Errorf("parseWeekAnchor(bad) = %s, want now", got)
|
||||
}
|
||||
if got := parseWeekAnchor("2026-10-12", now); got.Day() != 12 || got.Month() != time.October {
|
||||
t.Errorf("parseWeekAnchor(valid) = %s", got)
|
||||
}
|
||||
|
||||
if got := formatSeconds(0); got != "0m" {
|
||||
t.Errorf("formatSeconds(0) = %q", got)
|
||||
}
|
||||
if got := formatSeconds(3600); got != "1h 00m" {
|
||||
t.Errorf("formatSeconds(3600) = %q", got)
|
||||
}
|
||||
if got := formatSeconds(30); got != "under a minute" {
|
||||
t.Errorf("formatSeconds(30) = %q", got)
|
||||
}
|
||||
if got := percent(50, 100); got != 50 {
|
||||
t.Errorf("percent(50,100) = %d", got)
|
||||
}
|
||||
if got := percent(50, 0); got != 0 {
|
||||
t.Errorf("percent with max 0 = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user