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