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:
@@ -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