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