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:
@@ -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, `
|
||||
|
||||
Reference in New Issue
Block a user