Phase 6: schedules, blocklists, allowlists (editors + effective state)

- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-10-13 16:12:02 +11:00
parent 2f8e788fe6
commit 9af543c394
21 changed files with 2341 additions and 94 deletions

View File

@@ -16,7 +16,8 @@ import (
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// deviceView is the display model for one dashboard card.
// deviceView is the display model for one dashboard card. Locked and Until are
// the effective state: a manual lock or an active weekly window (Phase 6).
type deviceView struct {
ID int64
Name string
@@ -26,10 +27,13 @@ type deviceView struct {
Topic string
Online bool
Locked bool
ManualLocked bool
Scheduled bool
Until *time.Time
UntilLocal string
TimeLeft string
StateLabel string
ReasonLabel string
LastSeen string
HasLastSeen bool
}
@@ -44,9 +48,21 @@ type auditView struct {
CreatedAtLocal string
}
func buildDeviceViews(devs []store.Device) []deviceView {
func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState, loc *time.Location) []deviceView {
if loc == nil {
loc = time.Local
}
out := make([]deviceView, 0, len(devs))
for _, d := range devs {
eff, ok := effs[d.ID]
if !ok {
// No computed state (for example a store error): fall back to the
// manual lock so the card never lies about it.
eff = store.EffectiveState{DeviceID: d.ID, Locked: d.Locked, Until: d.Until}
if d.Locked {
eff.Reason = "manual"
}
}
v := deviceView{
ID: d.ID,
Name: d.Name,
@@ -55,21 +71,29 @@ func buildDeviceViews(devs []store.Device) []deviceView {
PlatformLabel: platformLabel(d.Platform),
Topic: d.Topic,
Online: d.Online,
Locked: d.Locked,
Until: d.Until,
Locked: eff.Locked,
ManualLocked: d.Locked,
Until: eff.Until,
}
if d.Locked {
switch eff.Reason {
case "schedule":
v.Scheduled = true
v.ReasonLabel = "by schedule"
case "manual":
v.ReasonLabel = "manually"
}
if eff.Locked {
v.StateLabel = "Locked"
} else {
v.StateLabel = "Unlocked"
}
if d.Until != nil {
local := d.Until.Local()
if eff.Until != nil {
local := eff.Until.In(loc)
v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*d.Until)
v.TimeLeft = timeLeft(*eff.Until)
}
if d.LastSeen != nil {
v.LastSeen = d.LastSeen.Local().Format("Mon 02 Jan 15:04")
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
v.HasLastSeen = true
}
out = append(out, v)
@@ -77,7 +101,10 @@ func buildDeviceViews(devs []store.Device) []deviceView {
return out
}
func buildAuditViews(entries []store.AuditEntry) []auditView {
func buildAuditViews(entries []store.AuditEntry, loc *time.Location) []auditView {
if loc == nil {
loc = time.Local
}
out := make([]auditView, 0, len(entries))
for _, e := range entries {
v := auditView{
@@ -88,7 +115,7 @@ func buildAuditViews(entries []store.AuditEntry) []auditView {
Detail: e.Detail,
}
if !e.CreatedAt.IsZero() {
v.CreatedAtLocal = e.CreatedAt.Local().Format("02 Jan 15:04:05")
v.CreatedAtLocal = e.CreatedAt.In(loc).Format("02 Jan 15:04:05")
}
out = append(out, v)
}
@@ -140,6 +167,12 @@ func auditLabel(action string) string {
return "Unlocked"
case "state.expired":
return "Lock expired"
case "schedule.updated":
return "Schedule saved"
case "list.added":
return "List entry added"
case "list.removed":
return "List entry removed"
case "device.online":
return "Device online"
case "device.offline":

387
internal/web/schedules.go Normal file
View File

@@ -0,0 +1,387 @@
// Weekly schedule and blocklist/allowlist editors (AGENTS.md §5 screens 3–4,
// §11 Phase 6). Only the Control Service writes desired state, so every save
// goes through the store, is audited, and republishes the device's retained
// config.
package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// defaultScheduleSlots is how many windows a day shows in the weekly grid. The
// grid grows if a device already has more, so a save never drops a window.
const defaultScheduleSlots = 3
// maxScheduleSlots bounds how many window slots the editor reads back from a
// submitted form.
const maxScheduleSlots = 12
// editorCommon carries the fields every editor page shares with the app shell.
type editorCommon struct {
Title string
Heading string
Sub string
Username string
CSRF string
Version string
AppEnv string
DeviceID int64
DeviceName string
PlatformLabel string
Error string
Flash string
}
// ---------- weekly schedule ----------
type scheduleSlotView struct {
Start string
End string
}
type scheduleRowView struct {
Weekday int
Name string
Slots []scheduleSlotView
}
type scheduleEditorData struct {
editorCommon
Rows []scheduleRowView
ActiveNow bool
ActiveUntil string
NextBoundary string
}
func buildScheduleRows(schedules []store.Schedule, minSlots int) []scheduleRowView {
byDay := make([][]scheduleSlotView, 7)
maxDay := 0
for _, sc := range schedules {
if sc.Weekday < store.Monday || sc.Weekday > store.Sunday {
continue
}
byDay[sc.Weekday] = append(byDay[sc.Weekday], scheduleSlotView{Start: sc.Start, End: sc.End})
if len(byDay[sc.Weekday]) > maxDay {
maxDay = len(byDay[sc.Weekday])
}
}
slots := minSlots
if maxDay > slots {
slots = maxDay
}
rows := make([]scheduleRowView, 0, 7)
for d := store.Monday; d <= store.Sunday; d++ {
row := scheduleRowView{Weekday: d, Name: store.WeekdayName(d)}
for i := 0; i < slots; i++ {
var slot scheduleSlotView
if i < len(byDay[d]) {
slot = byDay[d][i]
}
row.Slots = append(row.Slots, slot)
}
rows = append(rows, row)
}
return rows
}
func rowsFromWindows(windows []store.ScheduleWindow) []scheduleRowView {
schedules := make([]store.Schedule, 0, len(windows))
for _, w := range windows {
schedules = append(schedules, store.Schedule{Weekday: w.Weekday, Start: w.Start, End: w.End})
}
return buildScheduleRows(schedules, defaultScheduleSlots)
}
func (s *Server) handleScheduleEditor(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
schedules, err := s.store.ListSchedules(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.renderScheduleEditor(w, r, dev, buildScheduleRows(schedules, defaultScheduleSlots),
strings.TrimSpace(r.URL.Query().Get("err")),
strings.TrimSpace(r.URL.Query().Get("ok")))
}
func (s *Server) renderScheduleEditor(w http.ResponseWriter, r *http.Request, dev *store.Device, rows []scheduleRowView, errMsg, flash string) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
data := scheduleEditorData{
editorCommon: editorCommon{
Title: "Schedule — " + dev.Name,
Heading: "Weekly schedule",
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),
Error: errMsg,
Flash: flash,
},
Rows: rows,
}
now := s.now()
if schedules, err := s.store.ListSchedules(ctx, dev.ID); err == nil {
if active, until := store.ScheduleActive(schedules, now); active && until != nil {
data.ActiveNow = true
data.ActiveUntil = until.In(s.loc()).Format("Mon 02 Jan 15:04")
}
if boundary := store.NextWindowBoundary(schedules, now); boundary != nil {
data.NextBoundary = boundary.In(s.loc()).Format("Mon 02 Jan 15:04")
}
}
s.render(w, r, "schedule-editor.html", data)
}
func (s *Server) handleScheduleSave(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
var windows []store.ScheduleWindow
if r.PostFormValue("clear") == "" {
for weekday := store.Monday; weekday <= store.Sunday; weekday++ {
for slot := 0; slot < maxScheduleSlots; slot++ {
start := r.PostFormValue(fmt.Sprintf("start_%d_%d", weekday, slot))
end := r.PostFormValue(fmt.Sprintf("end_%d_%d", weekday, slot))
if strings.TrimSpace(start) == "" && strings.TrimSpace(end) == "" {
continue
}
windows = append(windows, store.ScheduleWindow{Weekday: weekday, Start: start, End: end})
}
}
}
if err := s.store.ReplaceSchedules(ctx, id, windows, s.cfg.AdminUsername); err != nil {
s.log.Warn("schedule rejected", "device", dev.Name, "err", err)
s.renderScheduleEditor(w, r, dev, rowsFromWindows(windows), err.Error(), "")
return
}
s.log.Info("schedule saved", "device", dev.Name, "windows", len(windows))
s.publishConfig(ctx, *dev)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/schedule?ok=%s", id,
url.QueryEscape("Saved schedule for "+dev.Name)), http.StatusSeeOther)
}
// ---------- blocklist / allowlist ----------
type listEntryView struct {
ID int64
Kind string
KindLabel string
Value string
}
type listsEditorData struct {
editorCommon
PlatformHint string
Block []listEntryView
Allow []listEntryView
}
func listEntryViews(entries []store.ListEntry) []listEntryView {
out := make([]listEntryView, 0, len(entries))
for _, e := range entries {
out = append(out, listEntryView{
ID: e.ID,
Kind: e.Kind,
KindLabel: e.Kind,
Value: e.Value,
})
}
return out
}
func platformHint(platform string) string {
switch platform {
case store.PlatformAndroid:
return "App values are Android package names (for example com.spotify.music). Site values are hostnames (for example youtube.com)."
case store.PlatformWindows:
return "App values are Windows process names (for example chrome.exe). Site values are hostnames (for example youtube.com)."
default:
return "App values are package or process names. Site values are hostnames."
}
}
func (s *Server) handleListsEditor(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.renderListsEditor(w, r, dev,
strings.TrimSpace(r.URL.Query().Get("err")),
strings.TrimSpace(r.URL.Query().Get("ok")))
}
func (s *Server) renderListsEditor(w http.ResponseWriter, r *http.Request, dev *store.Device, errMsg, flash string) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
block, err := s.store.ListEntries(ctx, dev.ID, store.TableBlocklist)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
allow, err := s.store.ListEntries(ctx, dev.ID, store.TableAllowlist)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.render(w, r, "lists-editor.html", listsEditorData{
editorCommon: editorCommon{
Title: "Lists — " + dev.Name,
Heading: "Blocklist and allowlist",
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),
Error: errMsg,
Flash: flash,
},
PlatformHint: platformHint(dev.Platform),
Block: listEntryViews(block),
Allow: listEntryViews(allow),
})
}
// listTableFromForm maps the editor's "block"/"allow" field to the store's
// table constant.
func listTableFromForm(value string) (string, error) {
switch strings.TrimSpace(value) {
case "block", store.TableBlocklist:
return store.TableBlocklist, nil
case "allow", store.TableAllowlist:
return store.TableAllowlist, nil
default:
return "", errors.New("list must be block or allow")
}
}
func (s *Server) handleListAdd(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
table, err := listTableFromForm(r.PostFormValue("table"))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?err=%s", id,
url.QueryEscape(err.Error())), http.StatusSeeOther)
return
}
kind := strings.ToLower(strings.TrimSpace(r.PostFormValue("kind")))
value := r.PostFormValue("value")
entry, err := s.store.AddListEntry(ctx, id, table, kind, value, s.cfg.AdminUsername)
if err != nil {
s.log.Warn("list entry rejected", "device", dev.Name, "err", err)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?err=%s", id,
url.QueryEscape(err.Error())), http.StatusSeeOther)
return
}
s.log.Info("list entry added", "device", dev.Name, "list", table, "kind", entry.Kind, "value", entry.Value)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?ok=%s", id,
url.QueryEscape("Added "+entry.Value)), http.StatusSeeOther)
}
func (s *Server) handleListDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
entryID, err := strconv.ParseInt(r.PathValue("entryID"), 10, 64)
if err != nil || entryID <= 0 {
http.NotFound(w, r)
return
}
if !s.requireCSRF(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
table, err := listTableFromForm(r.PostFormValue("table"))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?err=%s", id,
url.QueryEscape(err.Error())), http.StatusSeeOther)
return
}
if err := s.store.DeleteListEntry(ctx, id, entryID, table, s.cfg.AdminUsername); err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
s.deviceLookupError(w, r, err)
return
}
s.log.Info("list entry removed", "device", dev.Name, "list", table, "entry", entryID)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?ok=%s", id,
url.QueryEscape("Removed entry")), http.StatusSeeOther)
}

View File

@@ -0,0 +1,222 @@
package web
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
func createTestDevice(t *testing.T, app *testApp) *store.Device {
t.Helper()
rec := app.do(t, http.MethodPost, "/devices", url.Values{
"csrf": {app.csrf},
"name": {"Harry's Pixel"},
"child": {"Harry"},
"platform": {"android"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /devices = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
devs, err := app.store.ListDevices(t.Context())
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
if len(devs) != 1 {
t.Fatalf("devices = %d, want 1", len(devs))
}
return &devs[0]
}
func TestScheduleEditorSavesAndBlocksBySchedule(t *testing.T) {
app := newTestApp(t)
dev := createTestDevice(t, app)
// The empty grid renders.
rec := app.do(t, http.MethodGet, fmt.Sprintf("/devices/%d/schedule", dev.ID), nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Weekly schedule") {
t.Fatalf("GET schedule = %d", rec.Code)
}
// Save a window that covers now, whatever the local time is.
now := time.Now()
weekday := (int(now.Weekday()) + 6) % 7 // Monday based
start := now.Add(-1 * time.Hour).Format("15:04")
end := now.Add(1 * time.Hour).Format("15:04")
rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{
"csrf": {app.csrf},
fmt.Sprintf("start_%d_0", weekday): {start},
fmt.Sprintf("end_%d_0", weekday): {end},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST schedule = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
schedules, err := app.store.ListSchedules(t.Context(), dev.ID)
if err != nil {
t.Fatalf("ListSchedules: %v", err)
}
if len(schedules) != 1 || schedules[0].Start != start || schedules[0].End != end {
t.Fatalf("schedules = %+v, want one %s-%s", schedules, start, end)
}
// The dashboard reflects the schedule-driven block, with the reason.
rec = app.do(t, http.MethodGet, "/", nil)
if body := rec.Body.String(); !strings.Contains(body, "by schedule") {
t.Errorf("dashboard should say the device is blocked by schedule:\n%s", body)
}
// An invalid window is rejected and nothing is stored.
rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{
"csrf": {app.csrf},
"start_0_0": {"not-a-time"},
"end_0_0": {"10:00"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "HH:MM") {
t.Errorf("invalid window = %d, want the editor with an error", rec.Code)
}
schedules, _ = app.store.ListSchedules(t.Context(), dev.ID)
if len(schedules) != 1 {
t.Errorf("an invalid submit changed the schedules: %+v", schedules)
}
// Clear all removes the window.
rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{
"csrf": {app.csrf},
"clear": {"1"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("clear = %d, want 303", rec.Code)
}
schedules, _ = app.store.ListSchedules(t.Context(), dev.ID)
if len(schedules) != 0 {
t.Errorf("schedules after clear = %+v, want none", schedules)
}
// Every change is audited.
audit, err := app.store.ListAudit(t.Context(), 20)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
var updates int
for _, e := range audit {
if e.Action == "schedule.updated" {
updates++
}
}
if updates != 2 {
t.Errorf("schedule.updated audit entries = %d, want 2", updates)
}
}
func TestListsEditorAddAndRemove(t *testing.T) {
app := newTestApp(t)
dev := createTestDevice(t, app)
base := fmt.Sprintf("/devices/%d/lists", dev.ID)
rec := app.do(t, http.MethodGet, base, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Blocklist and allowlist") {
t.Fatalf("GET lists = %d", rec.Code)
}
// Allowlist Spotify and block other apps.
for _, form := range []url.Values{
{"csrf": {app.csrf}, "table": {"allow"}, "kind": {"app"}, "value": {"com.spotify.music"}},
{"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"com.youtube.android"}},
{"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"roblox.exe"}},
} {
rec = app.do(t, http.MethodPost, base, form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST list entry = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
}
rec = app.do(t, http.MethodGet, base, nil)
body := rec.Body.String()
for _, want := range []string{"com.spotify.music", "com.youtube.android", "roblox.exe", "Allowed", "Blocked"} {
if !strings.Contains(body, want) {
t.Errorf("lists page missing %q", want)
}
}
// A duplicate is refused with a message, not a second row.
rec = app.do(t, http.MethodPost, base, url.Values{
"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"com.youtube.android"},
})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "err=") {
t.Errorf("duplicate = %d %q, want a redirect with an error", rec.Code, rec.Header().Get("Location"))
}
// Remove one blocked app.
blocked, err := app.store.ListEntries(t.Context(), dev.ID, store.TableBlocklist)
if err != nil {
t.Fatalf("ListEntries: %v", err)
}
if len(blocked) != 2 {
t.Fatalf("blocklist = %+v, want 2", blocked)
}
rec = app.do(t, http.MethodPost,
fmt.Sprintf("/devices/%d/lists/%d/delete", dev.ID, blocked[0].ID),
url.Values{"csrf": {app.csrf}, "table": {"block"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d, want 303", rec.Code)
}
blocked, _ = app.store.ListEntries(t.Context(), dev.ID, store.TableBlocklist)
if len(blocked) != 1 || blocked[0].Value != "roblox.exe" {
t.Errorf("blocklist after delete = %+v", blocked)
}
// The allowlist is untouched.
allow, _ := app.store.ListEntries(t.Context(), dev.ID, store.TableAllowlist)
if len(allow) != 1 || allow[0].Value != "com.spotify.music" {
t.Errorf("allowlist = %+v", allow)
}
// The audit log records the adds and the remove.
audit, err := app.store.ListAudit(t.Context(), 20)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
var added, removed int
for _, e := range audit {
switch e.Action {
case "list.added":
added++
case "list.removed":
removed++
}
}
if added != 3 || removed != 1 {
t.Errorf("audit added=%d removed=%d, want 3 and 1", added, removed)
}
// Cross-device delete is a 404.
other := createTestDeviceNamed(t, app, "Finn PC", "Finn", "windows")
rec = app.do(t, http.MethodPost,
fmt.Sprintf("/devices/%d/lists/%d/delete", other.ID, blocked[0].ID),
url.Values{"csrf": {app.csrf}, "table": {"block"}})
if rec.Code != http.StatusNotFound {
t.Errorf("cross-device delete = %d, want 404", rec.Code)
}
}
func createTestDeviceNamed(t *testing.T, app *testApp, name, child, platform string) *store.Device {
t.Helper()
rec := app.do(t, http.MethodPost, "/devices", url.Values{
"csrf": {app.csrf},
"name": {name},
"child": {child},
"platform": {platform},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /devices = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
devs, err := app.store.ListDevices(t.Context())
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
return &devs[len(devs)-1]
}

View File

@@ -77,7 +77,7 @@ func (s *Server) publishConfig(ctx context.Context, dev store.Device) {
return
}
s.log.Info("mqtt config published", "device", dev.Name, "topic", dev.Topic,
"locked", dev.Locked)
"manual_locked", dev.Locked)
}
// Handler returns the fully wrapped HTTP handler.
@@ -96,6 +96,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /devices/{id}", s.requireAuth(s.handleDeviceUpdate))
mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock))
mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock))
mux.HandleFunc("GET /devices/{id}/schedule", s.requireAuth(s.handleScheduleEditor))
mux.HandleFunc("POST /devices/{id}/schedule", s.requireAuth(s.handleScheduleSave))
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))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -320,8 +325,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Devices: buildDeviceViews(devs),
Audit: buildAuditViews(audit),
Devices: buildDeviceViews(devs, s.effectiveStates(ctx, devs), s.loc()),
Audit: buildAuditViews(audit, s.loc()),
Flash: strings.TrimSpace(r.URL.Query().Get("ok")),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
})
@@ -333,11 +338,41 @@ func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) {
s.renderFragment(w, r, "status.html", s.buildStatusData(ctx))
}
// effectiveStates computes the desired state for every device at the same
// instant, so one dashboard load is internally consistent.
func (s *Server) effectiveStates(ctx context.Context, devs []store.Device) map[int64]store.EffectiveState {
out := make(map[int64]store.EffectiveState, len(devs))
now := s.now()
for _, d := range devs {
eff, err := s.store.EffectiveState(ctx, d, now)
if err != nil {
s.log.Error("effective state", "device", d.Name, "err", err)
continue
}
out[d.ID] = eff
}
return out
}
// now is the current time in the schedule timezone, so weekly windows are
// evaluated wall-clock and not in the container's default UTC.
func (s *Server) now() time.Time {
return time.Now().In(s.loc())
}
// loc is the schedule timezone, defaulting to the process local zone.
func (s *Server) loc() *time.Location {
if s.cfg.ScheduleLocation != nil {
return s.cfg.ScheduleLocation
}
return time.Local
}
func (s *Server) buildStatusData(ctx context.Context) statusData {
data := statusData{
DBHealthy: s.store.Ping(ctx) == nil,
DBPath: s.cfg.DBPath,
ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"),
ServerTime: time.Now().In(s.loc()).Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}
if s.pub != nil {

View File

@@ -464,6 +464,88 @@ a.button-primary:hover { text-decoration: none; }
.form-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
a.button-utility { text-decoration: none; }
/* ---------- Weekly schedule editor (Phase 6) ---------- */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.schedule-note {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
padding: var(--space-md) var(--space-lg);
display: grid;
gap: var(--space-xs);
}
.schedule-note__now { margin: 0; display: flex; align-items: center; gap: 8px; color: var(--ink); font: var(--type-body-md); }
.schedule-note__next { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); }
.schedule-note__hint { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); max-width: 70ch; }
.schedule-scroll { overflow-x: auto; }
.schedule-grid {
width: 100%;
border-collapse: collapse;
min-width: 520px;
}
.schedule-grid th,
.schedule-grid td {
text-align: left;
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--hairline);
vertical-align: middle;
}
.schedule-grid thead th {
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.schedule-grid tbody th { font: var(--type-body-md); color: var(--ink); white-space: nowrap; }
.schedule-grid__slot { white-space: nowrap; }
.schedule-grid__dash { padding: 0 6px; color: var(--ink-faint); }
.time-input { width: auto; min-width: 120px; display: inline-block; }
/* ---------- Blocklist / allowlist editor (Phase 6) ---------- */
.list-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-md);
align-items: start;
}
.list-panel { display: grid; gap: var(--space-sm); }
.list-panel__sub { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); }
.list-add { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
.list-add .text-input { flex: 1 1 160px; min-width: 0; }
.list-entries { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); }
.list-entry {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-sm);
padding: var(--space-xs) 0;
border-top: 1px solid var(--hairline);
}
.list-entry--empty { color: var(--ink-muted); font-style: italic; border-top: 0; }
.list-entry__kind {
padding: 2px 8px;
border-radius: var(--rounded-full);
background: var(--surface-2);
color: var(--ink-muted);
font: var(--type-eyebrow);
}
.list-entry__value { flex: 1 1 auto; word-break: break-all; }
.list-entry__remove { margin: 0; }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
@@ -474,4 +556,5 @@ a.button-utility { text-decoration: none; }
.nav-bar__user { display: none; }
.section__head { flex-wrap: wrap; }
.audit-item__meta { margin-left: 0; width: 100%; }
.list-add .button-primary { flex: 1 1 auto; }
}

View File

@@ -0,0 +1,114 @@
{{define "lists-editor.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">
</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>
</div>
</nav>
<main class="container">
<header class="page-head">
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
<h1 class="heading-1">{{.Heading}}</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="schedule-note">
<p class="schedule-note__hint">{{.PlatformHint}}</p>
<p class="schedule-note__hint">While a window is active or the device is locked, everything on the
blocklist is blocked <strong>except</strong> the allowlist. The allowlist is the exception list.</p>
</section>
<div class="list-grid">
<section class="card-panel list-panel" aria-labelledby="blocked-title">
<h2 id="blocked-title" class="heading-2">Blocked</h2>
<p class="list-panel__sub">Blocked during a window or lock.</p>
<form method="post" action="/devices/{{.DeviceID}}/lists" class="list-add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="table" value="block">
<select class="select-input" name="kind" aria-label="Blocked entry kind">
<option value="app">app</option>
<option value="site">site</option>
</select>
<input class="text-input" type="text" name="value" placeholder="com.youtube.android or youtube.com"
maxlength="200" required spellcheck="false" autocapitalize="none">
<button class="button-primary" type="submit">Add</button>
</form>
<ul class="list-entries">
{{range .Block}}
<li class="list-entry">
<span class="list-entry__kind">{{.KindLabel}}</span>
<code class="list-entry__value">{{.Value}}</code>
<form method="post" action="/devices/{{$.DeviceID}}/lists/{{.ID}}/delete" class="list-entry__remove">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="table" value="block">
<button class="button-utility" type="submit" aria-label="Remove {{.Value}} from the blocklist">Remove</button>
</form>
</li>
{{else}}
<li class="list-entry list-entry--empty">Nothing blocked yet.</li>
{{end}}
</ul>
</section>
<section class="card-panel list-panel" aria-labelledby="allowed-title">
<h2 id="allowed-title" class="heading-2">Allowed</h2>
<p class="list-panel__sub">Stays available while a block is active.</p>
<form method="post" action="/devices/{{.DeviceID}}/lists" class="list-add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="table" value="allow">
<select class="select-input" name="kind" aria-label="Allowed entry kind">
<option value="app">app</option>
<option value="site">site</option>
</select>
<input class="text-input" type="text" name="value" placeholder="com.spotify.music"
maxlength="200" required spellcheck="false" autocapitalize="none">
<button class="button-primary" type="submit">Add</button>
</form>
<ul class="list-entries">
{{range .Allow}}
<li class="list-entry">
<span class="list-entry__kind">{{.KindLabel}}</span>
<code class="list-entry__value">{{.Value}}</code>
<form method="post" action="/devices/{{$.DeviceID}}/lists/{{.ID}}/delete" class="list-entry__remove">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="table" value="allow">
<button class="button-utility" type="submit" aria-label="Remove {{.Value}} from the allowlist">Remove</button>
</form>
</li>
{{else}}
<li class="list-entry list-entry--empty">Nothing allowed yet.</li>
{{end}}
</ul>
</section>
</div>
<div class="form-actions">
<a class="button-utility" href="/devices/{{.DeviceID}}/schedule">Weekly schedule</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}}

View File

@@ -12,7 +12,7 @@
</header>
<p class="device-card__state">
{{if .Locked}}Blocked{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
{{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
</p>
{{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}}
@@ -34,6 +34,8 @@
</form>
{{end}}
<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>
</div>
<p class="device-card__foot">

View File

@@ -0,0 +1,91 @@
{{define "schedule-editor.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">
</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>
</div>
</nav>
<main class="container">
<header class="page-head">
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
<h1 class="heading-1">{{.Heading}}</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="schedule-note">
{{if .ActiveNow}}
<p class="schedule-note__now"><span class="dot dot--off"></span> A window is active now{{if .ActiveUntil}} until {{.ActiveUntil}}{{end}}.</p>
{{else}}
<p class="schedule-note__now"><span class="dot dot--faint"></span> No window is active now.</p>
{{end}}
{{if .NextBoundary}}<p class="schedule-note__next">Next change: {{.NextBoundary}}.</p>{{end}}
<p class="schedule-note__hint">Each row is a day. A window means restrictions are ON during that time.
Leave both boxes empty to remove a window. An end time earlier than the start crosses midnight.</p>
</section>
<section class="card-panel">
<form method="post" action="/devices/{{.DeviceID}}/schedule" class="stack-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="schedule-scroll">
<table class="schedule-grid">
<caption class="visually-hidden">Weekly blocking windows for {{.DeviceName}}</caption>
<thead>
<tr>
<th scope="col">Day</th>
{{range index .Rows 0}}{{range .Slots}}<th scope="col">Window</th>{{end}}{{end}}
</tr>
</thead>
<tbody>
{{range $row := .Rows}}
<tr>
<th scope="row">{{$row.Name}}</th>
{{range $i, $slot := $row.Slots}}
<td class="schedule-grid__slot">
<input class="text-input time-input" type="time" name="start_{{$row.Weekday}}_{{$i}}"
value="{{$slot.Start}}" aria-label="{{$row.Name}} window {{$i}} start">
<span class="schedule-grid__dash" aria-hidden="true">–</span>
<input class="text-input time-input" type="time" name="end_{{$row.Weekday}}_{{$i}}"
value="{{$slot.End}}" aria-label="{{$row.Name}} window {{$i}} end">
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="form-actions">
<button class="button-primary" type="submit">Save schedule</button>
<button class="button-utility" type="submit" name="clear" value="1" formnovalidate>Clear all</button>
<a class="button-utility" href="/devices/{{.DeviceID}}/lists">Blocklist / allowlist</a>
<a class="button-utility" href="/">Done</a>
</div>
</form>
</section>
</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}}