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

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