From 0c3a0844e451bc8eef1da45ccd5343273f21fea5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 20:37:45 +0100 Subject: [PATCH 01/16] docs(spec): always-on vs intermittent host mode design --- .../2026-06-15-always-on-host-mode-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/specs/2026-06-15-always-on-host-mode-design.md diff --git a/docs/specs/2026-06-15-always-on-host-mode-design.md b/docs/specs/2026-06-15-always-on-host-mode-design.md new file mode 100644 index 0000000..a10a089 --- /dev/null +++ b/docs/specs/2026-06-15-always-on-host-mode-design.md @@ -0,0 +1,217 @@ +# Always-On vs Intermittent host mode + +**Date:** 2026-06-15 +**Branch:** `feat-laptop-host-mode` +**Status:** Design — awaiting review + +## Problem + +The server currently assumes every host should be present 24×7. When an +agent stops heartbeating for 90s it is flipped to `offline`, and after 15 +minutes that raises a `warning` alert. This is correct for a server, but +wrong for a host that legitimately comes and goes — a workstation or +laptop that sleeps overnight, travels, or is shut down on weekends. Such +a host generates noise alerts every time it is closed, and — more +importantly — there is **no mechanism to catch up a backup it missed +while it was away.** + +Two distinct facts make the catch-up gap real: + +- **Backup cron runs on the agent, locally.** The agent fires + `MsgScheduleFire`; the server only dispatches in response. If the host + is asleep, the agent process is suspended, so the cron tick never + fires and no `MsgScheduleFire` is ever sent. +- Therefore the existing `pending_runs` retry queue **does not** cover + this case. `pending_runs` only gets a row when a schedule *fired* but + the agent was momentarily disconnected at dispatch time. A window + missed entirely during sleep never enqueues anything. + +## Goal + +Let an operator mark a host as **not** always-on. Such a host: + +1. Does **not** raise offline/agent-down alerts when it is not visible. +2. Renders a distinct, calm "asleep" state in the UI instead of the + alarming red "offline". +3. When it reconnects, after a short settle delay, the server checks + whether it missed a scheduled backup and — if so — triggers a + catch-up backup automatically. +4. Still raises a *staleness* alert if it has genuinely gone too long + without any backup (a host left in a drawer), and still raises normal + job-failure alerts for backups that run and fail. + +Default behaviour is unchanged for the entire existing fleet. + +## Decisions (from brainstorming) + +- **Setting shape:** a single boolean `Always On` checkbox per host, + **default ON**. Checked = today's 24×7 server semantics. Unchecked = + intermittent host. Opt-in only; zero behaviour change for current and + future hosts unless explicitly toggled. +- **Overdue trigger:** evaluated on **reconnect + behind schedule** + (not a continuous always-evaluating sweep). +- **Alert policy for intermittent hosts:** suppress offline alerts; + keep a long-threshold **staleness** alert; keep job-failure alerts. +- **Staleness threshold:** **7 days**, a global constant for v1. May + become per-host configurable later — out of scope now. +- **Catch-up granularity:** **per enabled schedule.** A host with a + daily and a weekly schedule catches up only whichever is actually + behind. +- **UI vocabulary:** not-visible intermittent host shows a grey + `asleep` state; detail line reads + `asleep · last seen · will catch up on return`. +- **Chip:** chip and checkbox highlight the **same** truth (24×7). Show + a chip for **Always-On** hosts; **no** chip for intermittent. + +## Architecture + +The change is deliberately a thin policy + presentation layer over the +existing online/offline state machine. We do **not** add a new `status` +enum value or alter heartbeat / `last_seen_at` tracking. "Asleep" is a +reinterpretation of `status='offline' AND NOT always_on`. + +### 1. Data model + +- **Migration `0024_hosts_always_on.sql`:** + ```sql + ALTER TABLE hosts ADD COLUMN always_on INTEGER NOT NULL DEFAULT 1; + ``` + Column-level ALTER per the repo's migration rules. Default `1` means + every existing row is Always-On — no behaviour change on upgrade. +- `store/types.go`: add `AlwaysOn bool` to the `Host` struct; thread it + through every host SELECT scan and the host insert/update paths. +- New store helper `SetHostAlwaysOn(ctx, hostID, bool) error`. + +### 2. Online/offline mechanics — UNCHANGED + +The 30s offline sweeper (`cmd/server/main.go:220`) still flips an unseen +host to `status='offline'` and still calls +`alertEngine.NotifyHostOffline(id)`. `TouchHost` / `MarkHostHello` +behaviour is untouched. The intermittent distinction is applied +*downstream* of this state, in the alert engine and the templates. + +### 3. Alert behaviour + +All changes key off `host.AlwaysOn`, which the engine already has access +to via the host row it loads. + +- **Suppress offline alert** (`alert/engine.go` `handleHostOffline()` + and the 60s `tick()`): when `!host.AlwaysOn`, do not raise + `agent_offline`. +- **Resolve-on-toggle:** when a host is switched server→intermittent and + has an open `agent_offline` alert, auto-resolve it. (Handled in the + mode-change handler, fanning through the normal resolve path so + channels/audit fire as usual.) +- **Staleness alert** — wire up the currently-dead `KindStaleSchedule` + constant, **for intermittent hosts only.** On the 60s tick, for each + host where `!AlwaysOn` AND the host has ≥1 enabled schedule AND + `LastBackupAt != nil` AND `now - LastBackupAt > 7*24h`: raise a + `warning` `stale_schedule` alert (dedup key `""`, one per host). + Auto-resolves when `LastBackupAt` advances past the threshold (i.e. + any successful backup, including the catch-up). Always-On hosts' + `stale_schedule` remains a no-op (unchanged, out of scope). + - If `LastBackupAt == nil` (intermittent host enrolled but never + backed up): no staleness alert in v1 — there is no baseline to + measure against, and onboarding probe state (`repo_status`) already + covers "never successfully set up." +- **Job-failure alerts:** untouched. A catch-up backup that runs and + fails alerts exactly like any other backup. + +### 4. Catch-up on reconnect + +A new small component — the **catch-up scheduler** — lives server-side +alongside the existing ticks. + +- **Arm:** on agent hello (`server/ws/handler.go` hello path / + `onAgentHello`), if the host is `!AlwaysOn`, record + `catchupDueAt[hostID] = now + 60s` in an in-memory map. Re-arming on a + subsequent hello just overwrites the timestamp (debounce — rapid + flapping does not stack catch-ups). In-memory is acceptable: catch-up + is best-effort and a server restart simply re-arms on the next hello. +- **Fire:** reuse the existing 30s server tick. For each due entry + (`catchupDueAt <= now`): + 1. Re-verify the agent is still connected (`Hub.Connected(hostID)`). + If it bounced back offline within the settle window, drop the entry + (it will re-arm on the next hello). + 2. Skip if a backup is already running or queued for the host + (`current_job_id` set, or a relevant `pending_runs` row exists) — + avoid double-firing alongside a normal dispatch or pending drain. + 3. For each **enabled** schedule on the host, compute overdue: + ``` + overdue := sched.Next(host.LastBackupAt) <= now + ``` + using `robfig/cron/v3` (already a dependency) to parse + `Schedule.CronExpr`. `Next(lastBackup)` is the first fire strictly + after the last successful backup; if that moment has already + passed, the window was missed → overdue. (If `LastBackupAt` is nil, + treat as overdue so a never-backed-up intermittent host with a + schedule gets its first run on connect.) + 4. For each overdue schedule, dispatch its source-groups via the + existing `dispatchBackupForGroupCore()`. + 5. Clear the entry. + +Net latency is ~60–90s after wake (60s settle + up to one 30s tick). +This path is independent of and complementary to the `pending_runs` +drain, which continues to handle the fired-but-not-sent case. + +### 5. UI + +- **CSS:** new grey `dot-asleep` token in `web/styles/input.css`, + visually distinct from red `dot-offline`. +- **`partials/host_row.html` and `partials/host_chrome.html`:** when + `!AlwaysOn && status=='offline'`, render the grey dot + label + `asleep`; the detail/last-seen line reads + `asleep · last seen · will catch up on return`. All other + states unchanged. +- **24×7 chip:** on the host detail header, render a small + `Always On` / `24×7` chip **only when `AlwaysOn` is true**. No chip + for intermittent hosts. (Chip and checkbox highlight the same fact.) +- **Toggle:** an `Always On` checkbox (default checked) on the host edit + surface. Operator-band `POST` (mirrors existing host-edit handlers), + audited as `host.mode_updated`. On save, if switching to intermittent, + trigger the resolve-on-toggle path for any open `agent_offline` alert. + +## Error handling & edge cases + +- **Toggle server→intermittent while offline+alerting:** open + `agent_offline` alert auto-resolved on save. +- **Toggle intermittent→server while asleep:** host resumes normal + offline/alert semantics; it will alert per the 15-minute floor once + the sweeper/tick next evaluates it. +- **No enabled schedules:** no catch-up and no staleness alert — there + is no backup expectation to measure against. +- **Catch-up vs in-flight work:** guarded by the running/queued check in + step 4.2 so catch-up never races a normal dispatch or pending drain. +- **Agent flaps during settle window:** entry dropped if not connected + at fire time; re-armed on the next hello. + +## Testing + +- **Alert engine (unit):** + - offline alert suppressed when `!AlwaysOn`. + - staleness alert raised when intermittent + schedule + last backup > + 7d; not raised for Always-On hosts; not raised when last backup is + recent; not raised when no enabled schedule. + - staleness alert auto-resolves after a backup advances `LastBackupAt`. + - server→intermittent toggle resolves an open `agent_offline` alert. +- **Overdue computation (unit, table-driven):** `(cronExpr, + lastBackupAt, now) → overdue?` including nil-last-backup and + daily/weekly cases. +- **Catch-up scheduler (unit):** fires only when still connected; skips + when a backup is running/queued; dispatches only overdue schedules. +- **UI (render test):** asleep state + 24×7 chip render under the right + conditions; offline state for Always-On hosts unchanged. +- `go vet ./...` and full `go test ./...` green before merge. + +## Out of scope + +- Per-host staleness thresholds (global 7d constant for v1). +- Continuous (non-reconnect) overdue evaluation. +- Agent-side catch-up cron — the server is the reliable arbiter. +- Wiring `stale_schedule` for Always-On hosts (separate concern). + +## Task tracking + +Add an entry to `tasks.md` under "Next steps from testing" (or a new +small section) once the plan is approved, per the repo's tasks.md +source-of-truth rule. From 261b83ec26bfd009209977eeb833d955fa1a51d5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 20:42:00 +0100 Subject: [PATCH 02/16] docs(spec): clarify staleness vs job-failure alerting for asleep hosts --- docs/specs/2026-06-15-always-on-host-mode-design.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/specs/2026-06-15-always-on-host-mode-design.md b/docs/specs/2026-06-15-always-on-host-mode-design.md index a10a089..b48c8e6 100644 --- a/docs/specs/2026-06-15-always-on-host-mode-design.md +++ b/docs/specs/2026-06-15-always-on-host-mode-design.md @@ -37,8 +37,14 @@ Let an operator mark a host as **not** always-on. Such a host: whether it missed a scheduled backup and — if so — triggers a catch-up backup automatically. 4. Still raises a *staleness* alert if it has genuinely gone too long - without any backup (a host left in a drawer), and still raises normal - job-failure alerts for backups that run and fail. + without any backup (a host left in a drawer). This is the only + alert covering an asleep host: while the agent is offline no job + runs, so there is no failure to detect — staleness is the safety + net for "no backups are happening at all." +5. Leaves normal job-failure alerting untouched: a backup that + actually runs (scheduled or catch-up) and fails alerts as it does + today. Failures can only occur while the agent is online and + executing restic. Default behaviour is unchanged for the entire existing fleet. From 9d16e3f7e3d9b96c337624d26763a218e7050b33 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 20:48:16 +0100 Subject: [PATCH 03/16] docs(plan): always-on vs intermittent host mode implementation plan --- docs/plans/2026-06-15-always-on-host-mode.md | 1060 ++++++++++++++++++ 1 file changed, 1060 insertions(+) create mode 100644 docs/plans/2026-06-15-always-on-host-mode.md diff --git a/docs/plans/2026-06-15-always-on-host-mode.md b/docs/plans/2026-06-15-always-on-host-mode.md new file mode 100644 index 0000000..76fdf6c --- /dev/null +++ b/docs/plans/2026-06-15-always-on-host-mode.md @@ -0,0 +1,1060 @@ +# Always-On vs Intermittent Host Mode — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an operator mark a host as not-always-on so it stops raising offline alerts when it legitimately sleeps, renders a calm "asleep" state, auto-catches-up a missed backup ~1 minute after it reconnects, and still raises a long-threshold staleness alert if it goes too long with no backup. + +**Architecture:** A thin policy + presentation layer over the existing online/offline state machine. A new `hosts.always_on` boolean (default 1 = today's behaviour) gates three behaviours: offline-alert suppression + a 7-day staleness alert in the alert engine; an in-memory catch-up scheduler in the HTTP server armed on agent hello and fired from the existing 30s tick; and an "asleep" UI state plus a 24×7 chip. Online/offline tracking, heartbeat, and `pending_runs` are untouched. + +**Tech Stack:** Go, SQLite (modernc), `github.com/robfig/cron/v3` (already a dependency), Go `html/template`, Tailwind-in-`input.css`. + +**Spec:** `docs/specs/2026-06-15-always-on-host-mode-design.md` + +--- + +## File Structure + +- **Create** `internal/store/migrations/0024_hosts_always_on.sql` — add the column. +- **Modify** `internal/store/types.go` — add `Host.AlwaysOn bool`. +- **Modify** `internal/store/hosts.go` — add `always_on` to the 3 host SELECTs + `scanHostRow`; add `SetHostAlwaysOn`. +- **Create** `internal/store/hosts_always_on_test.go` — round-trip + default test. +- **Modify** `internal/alert/engine.go` — suppress offline for intermittent hosts; staleness sweep; resolve staleness on backup success. +- **Modify** `internal/alert/rules.go` — exported `ResolveKind` helper for the toggle handler; staleness threshold constant. +- **Create** `internal/alert/intermittent_test.go` — suppression + staleness + resolve tests. +- **Create** `internal/server/http/catchup.go` — overdue helper + in-memory catch-up scheduler. +- **Create** `internal/server/http/catchup_test.go` — overdue table tests. +- **Modify** `internal/server/http/server.go` — catch-up map fields on `Server`, init in `New`. +- **Modify** `internal/server/http/host_credentials.go` — arm catch-up in `onAgentHello`. +- **Modify** `cmd/server/main.go` — call `srv.RunCatchupsDue` on the pending-drain tick. +- **Modify** `internal/server/http/ui_handlers.go` — `handleUIHostModeSave` handler. +- **Modify** `internal/server/http/server.go` (routes) — mount `POST /hosts/{id}/mode`. +- **Modify** `web/styles/input.css` — `dot-asleep` token. +- **Modify** `web/templates/partials/host_row.html` — asleep dot + text. +- **Modify** `web/templates/partials/host_chrome.html` — asleep dot/last-seen, 24×7 chip, mode toggle form. +- **Modify** `tasks.md` — record the feature. + +--- + +## Task 1: Schema + store field for `always_on` + +**Files:** +- Create: `internal/store/migrations/0024_hosts_always_on.sql` +- Modify: `internal/store/types.go:62-102` (Host struct) +- Modify: `internal/store/hosts.go` (3 SELECTs at lines 41-48, 56-63, 224-231; `scanHostRow` at 261-334) +- Test: `internal/store/hosts_always_on_test.go` + +- [ ] **Step 1: Write the migration** + +Create `internal/store/migrations/0024_hosts_always_on.sql`: + +```sql +-- 0024: distinguish always-on (24x7 server) hosts from intermittent +-- hosts (laptops/workstations that legitimately sleep). Default 1 so +-- every existing and future host keeps today's offline/alert +-- semantics unless explicitly opted out. Column-level ALTER per the +-- repo's migration rules (no table rebuild — hosts has inbound FKs). +ALTER TABLE hosts ADD COLUMN always_on INTEGER NOT NULL DEFAULT 1; +``` + +- [ ] **Step 2: Add the struct field** + +In `internal/store/types.go`, add to the `Host` struct (after `RepoStatusError` at line 101): + +```go + // AlwaysOn is true for 24x7 server hosts (the default). When false + // the host is intermittent (laptop/workstation): offline alerts are + // suppressed, the UI shows an "asleep" state, and a missed backup is + // caught up ~1 min after reconnect. See the always-on-host-mode spec. + AlwaysOn bool +``` + +- [ ] **Step 3: Thread `always_on` through reads** + +In `internal/store/hosts.go`, append `, always_on` to the SELECT column list in all three queries: `LookupHostByAgentToken` (line 47), `GetHost` (line 62), and `ListHosts` (line 230). Each currently ends `repo_status, repo_status_error` — change to `repo_status, repo_status_error, always_on`. + +Then in `scanHostRow` (line 261), add scanning. Add a local var and the scan target. Change the `Scan(...)` call's final args from `&h.RepoStatus, &h.RepoStatusError)` to `&h.RepoStatus, &h.RepoStatusError, &alwaysOn)` and declare `var alwaysOn int` in the var block, then after the existing post-scan assignments add: + +```go + h.AlwaysOn = alwaysOn != 0 +``` + +(SQLite stores the boolean as INTEGER; scan into int then compare to avoid driver bool-coercion surprises.) + +- [ ] **Step 4: Add `SetHostAlwaysOn`** + +In `internal/store/hosts.go`, after `SetHostTags` (line 379), add: + +```go +// SetHostAlwaysOn flips the host's always-on flag. true = 24x7 server +// (default); false = intermittent host (laptop). See the +// always-on-host-mode spec. +func (s *Store) SetHostAlwaysOn(ctx context.Context, hostID string, alwaysOn bool) error { + v := 0 + if alwaysOn { + v = 1 + } + _, err := s.db.ExecContext(ctx, + `UPDATE hosts SET always_on = ? WHERE id = ?`, v, hostID) + if err != nil { + return fmt.Errorf("store: set host always_on: %w", err) + } + return nil +} +``` + +- [ ] **Step 5: Write the round-trip test** + +Create `internal/store/hosts_always_on_test.go`. Use the existing test harness pattern — check a sibling test (e.g. `internal/store/hosts_test.go`) for the `newTestStore`/`testStore` helper name and the host-creation helper, and mirror it exactly. The test body: + +```go +package store + +import ( + "context" + "testing" + "time" +) + +func TestHostAlwaysOnDefaultAndToggle(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) // mirror the helper used by hosts_test.go + + h := Host{ + ID: "h-always-on", Name: "lap", OS: "linux", Arch: "amd64", + ProtocolVersion: 1, EnrolledAt: time.Now().UTC(), + } + if err := st.CreateHost(ctx, h, "tok-hash", "pin"); err != nil { + t.Fatalf("create host: %v", err) + } + + got, err := st.GetHost(ctx, h.ID) + if err != nil { + t.Fatalf("get host: %v", err) + } + if !got.AlwaysOn { + t.Fatalf("new host should default to always_on=true, got false") + } + + if err := st.SetHostAlwaysOn(ctx, h.ID, false); err != nil { + t.Fatalf("set always_on: %v", err) + } + got, err = st.GetHost(ctx, h.ID) + if err != nil { + t.Fatalf("get host 2: %v", err) + } + if got.AlwaysOn { + t.Fatalf("expected always_on=false after toggle, got true") + } + + // ListHosts must surface the same value. + hosts, err := st.ListHosts(ctx) + if err != nil { + t.Fatalf("list hosts: %v", err) + } + if len(hosts) != 1 || hosts[0].AlwaysOn { + t.Fatalf("ListHosts should report always_on=false, got %+v", hosts) + } +} +``` + +- [ ] **Step 6: Run the test (expect FAIL first if written before code, else PASS)** + +Run: `go test ./internal/store/ -run TestHostAlwaysOnDefaultAndToggle -v` +Expected: PASS once Steps 1-4 are in. If you wrote the test first, it fails to compile on `AlwaysOn` / `SetHostAlwaysOn` — that is the expected red. + +- [ ] **Step 7: Commit** + +```bash +go vet ./internal/store/... +git add internal/store/migrations/0024_hosts_always_on.sql internal/store/types.go internal/store/hosts.go internal/store/hosts_always_on_test.go +git commit -m "feat(store): add hosts.always_on flag (default on)" +``` + +--- + +## Task 2: Overdue computation helper + +This is a pure function so it can be unit-tested in isolation before the scheduler wires it up. It lives in the new `catchup.go` (the scheduler will follow in Task 3, same file). + +**Files:** +- Create: `internal/server/http/catchup.go` +- Test: `internal/server/http/catchup_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/server/http/catchup_test.go`: + +```go +package http + +import ( + "testing" + "time" +) + +func TestScheduleOverdue(t *testing.T) { + mustParse := func(s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return v + } + daily := "0 2 * * *" // 02:00 every day + + cases := []struct { + name string + cron string + lastBackup *time.Time + now time.Time + want bool + }{ + { + name: "never backed up is overdue", + cron: daily, lastBackup: nil, + now: mustParse("2026-06-15T09:00:00Z"), + want: true, + }, + { + name: "missed last nights window", + cron: daily, + lastBackup: ptrTime(mustParse("2026-06-13T02:05:00Z")), + now: mustParse("2026-06-15T09:00:00Z"), + want: true, + }, + { + name: "backed up after the most recent window", + cron: daily, + lastBackup: ptrTime(mustParse("2026-06-15T02:05:00Z")), + now: mustParse("2026-06-15T09:00:00Z"), + want: false, + }, + { + name: "unparseable cron is never overdue", + cron: "not a cron", + lastBackup: nil, + now: mustParse("2026-06-15T09:00:00Z"), + want: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := scheduleOverdue(c.cron, c.lastBackup, c.now) + if got != c.want { + t.Fatalf("scheduleOverdue(%q, %v, %v) = %v, want %v", + c.cron, c.lastBackup, c.now, got, c.want) + } + }) + } +} + +func ptrTime(t time.Time) *time.Time { return &t } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/server/http/ -run TestScheduleOverdue -v` +Expected: FAIL — `undefined: scheduleOverdue`. + +- [ ] **Step 3: Implement `scheduleOverdue`** + +Create `internal/server/http/catchup.go` with the helper (the scheduler methods are added in Task 3): + +```go +// catchup.go — server-side catch-up for intermittent (non-always-on) +// hosts. When such a host reconnects we wait a short settle window, +// then dispatch a backup for any schedule whose window elapsed while +// the host was asleep. This is separate from pending_runs: a host that +// was asleep never fired its local cron, so no pending row exists. +package http + +import ( + "time" +) + +// scheduleOverdue reports whether a schedule's most recent expected +// fire is newer than the host's last successful backup — i.e. a window +// passed with no backup. A nil lastBackup means "never backed up" and +// is always overdue (provided the cron parses). An unparseable cron is +// treated as not-overdue so a bad expression can never trigger a +// surprise dispatch. Uses the same cronParser the agent's scheduler +// and schedule validation use, so interpretation is identical. +func scheduleOverdue(cronExpr string, lastBackup *time.Time, now time.Time) bool { + sched, err := cronParser.Parse(cronExpr) + if err != nil { + return false + } + if lastBackup == nil { + return true + } + next := sched.Next(*lastBackup) + return !next.After(now) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/server/http/ -run TestScheduleOverdue -v` +Expected: PASS (all four sub-cases). + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/server/http/... +git add internal/server/http/catchup.go internal/server/http/catchup_test.go +git commit -m "feat(catchup): scheduleOverdue helper for missed-window detection" +``` + +--- + +## Task 3: Catch-up scheduler (arm on hello, fire on tick) + +**Files:** +- Modify: `internal/server/http/server.go:68-93` (Server struct), `:96-112` (New) +- Modify: `internal/server/http/catchup.go` (add scheduler methods) +- Modify: `internal/server/http/host_credentials.go:463-486` (onAgentHello) +- Modify: `cmd/server/main.go:228-229` (pending-drain tick case) + +- [ ] **Step 1: Add catch-up state to the Server struct** + +In `internal/server/http/server.go`, add fields to `Server` (after `treeCache` at line 92): + +```go + // catchupDueAt tracks intermittent hosts that reconnected and are + // in their settle window. Keyed hostID → earliest time to evaluate + // catch-up. Best-effort + in-memory: a server restart simply re-arms + // on the next hello. Guarded by catchupMu. + catchupMu sync.Mutex + catchupDueAt map[string]time.Time +``` + +Add `"time"` to the imports if not already present (check the import block). + +- [ ] **Step 2: Initialise the map in New** + +In `New` (line 106), add to the `&Server{...}` literal: + +```go + catchupDueAt: make(map[string]time.Time), +``` + +- [ ] **Step 3: Add scheduler methods to catchup.go** + +Append to `internal/server/http/catchup.go`. Add `"context"`, `"log/slog"` to its imports: + +```go +// catchupSettle is how long after a reconnect we wait before evaluating +// catch-up, so a laptop that wakes briefly and sleeps again doesn't +// trigger a backup it can't finish. ~1 minute per the spec. +const catchupSettle = 60 * time.Second + +// ArmCatchup records that an intermittent host just reconnected and +// should be evaluated for a missed backup after the settle window. +// No-op for always-on hosts (caller passes only intermittent hosts). +// Re-arming overwrites the timer (debounce — flapping doesn't stack). +func (s *Server) ArmCatchup(hostID string, now time.Time) { + s.catchupMu.Lock() + defer s.catchupMu.Unlock() + if s.catchupDueAt == nil { + s.catchupDueAt = make(map[string]time.Time) + } + s.catchupDueAt[hostID] = now.Add(catchupSettle) +} + +// dueCatchups returns the hostIDs whose settle window has elapsed and +// removes them from the map. Caller evaluates each. +func (s *Server) dueCatchups(now time.Time) []string { + s.catchupMu.Lock() + defer s.catchupMu.Unlock() + var due []string + for id, at := range s.catchupDueAt { + if !now.Before(at) { + due = append(due, id) + delete(s.catchupDueAt, id) + } + } + return due +} + +// RunCatchupsDue is the tick entrypoint. For each host past its settle +// window it dispatches a backup for every enabled schedule that is +// overdue. Skips hosts that bounced back offline, that are already +// running/queued a job, or that turned out to be always-on. +func (s *Server) RunCatchupsDue(ctx context.Context) { + if s.deps.Hub == nil { + return + } + now := time.Now().UTC() + for _, hostID := range s.dueCatchups(now) { + s.runCatchup(ctx, hostID, now) + } +} + +// runCatchup evaluates and dispatches catch-up backups for a single +// host. Exported logic kept here so RunCatchupsDue reads cleanly. +func (s *Server) runCatchup(ctx context.Context, hostID string, now time.Time) { + conn := s.deps.Hub.Conn(hostID) + if conn == nil { + return // bounced offline during the settle window; re-arms on next hello + } + host, err := s.deps.Store.GetHost(ctx, hostID) + if err != nil { + slog.Warn("catchup: load host", "host_id", hostID, "err", err) + return + } + if host.AlwaysOn { + return // mode flipped during settle window + } + if host.CurrentJobID != nil { + return // a job is already running; don't pile on + } + schedules, err := s.deps.Store.ListSchedulesByHost(ctx, hostID) + if err != nil { + slog.Warn("catchup: list schedules", "host_id", hostID, "err", err) + return + } + for _, sc := range schedules { + if !sc.Enabled || len(sc.SourceGroupIDs) == 0 { + continue + } + if !scheduleOverdue(sc.CronExpr, host.LastBackupAt, now) { + continue + } + for _, gid := range sc.SourceGroupIDs { + g, err := s.deps.Store.GetSourceGroup(ctx, hostID, gid) + if err != nil { + slog.Warn("catchup: load source group", + "host_id", hostID, "schedule_id", sc.ID, "group_id", gid, "err", err) + continue + } + if _, derr := s.dispatchBackupForGroupCore(ctx, conn, hostID, sc.ID, g, now); derr != nil { + // Send failed — host dropped again. Re-arm so the next + // reconnect retries; stop processing this host. + s.ArmCatchup(hostID, now) + return + } + slog.Info("catchup: dispatched missed backup", + "host_id", hostID, "schedule_id", sc.ID, "group", g.Name) + } + } +} +``` + +- [ ] **Step 4: Arm catch-up on agent hello** + +In `internal/server/http/host_credentials.go`, in `onAgentHello` (line 463), after the `go s.DrainPending(...)` line (485), add: + +```go + // Intermittent hosts that just reconnected may have slept through a + // backup window. Arm a catch-up evaluation after a settle delay; the + // pending-drain tick fires it. Always-on hosts never need this. + if host, err := s.deps.Store.GetHost(ctx, hostID); err == nil && !host.AlwaysOn { + s.ArmCatchup(hostID, time.Now().UTC()) + } +``` + +Verify `time` is already imported in this file (it is — used elsewhere). If not, add it. + +- [ ] **Step 5: Fire catch-up from the pending-drain tick** + +In `cmd/server/main.go`, in the `case <-pendingDrainTick.C:` block (line 228), change: + +```go + case <-pendingDrainTick.C: + srv.DrainAllDue(ctx) +``` + +to: + +```go + case <-pendingDrainTick.C: + srv.DrainAllDue(ctx) + srv.RunCatchupsDue(ctx) +``` + +- [ ] **Step 6: Build and vet** + +Run: `go build ./... && go vet ./...` +Expected: clean build, no vet errors. + +- [ ] **Step 7: Commit** + +```bash +git add internal/server/http/server.go internal/server/http/catchup.go internal/server/http/host_credentials.go cmd/server/main.go +git commit -m "feat(catchup): arm on hello, fire missed-window backups on tick" +``` + +--- + +## Task 4: Alert engine — suppress offline + staleness alert + +**Files:** +- Modify: `internal/alert/engine.go:121-153` (handleJobFinished), `:155-174` (handleHostOffline), `:188-216` (tick) +- Modify: `internal/alert/rules.go:13-39` (constants), add exported resolve helper +- Test: `internal/alert/intermittent_test.go` + +- [ ] **Step 1: Add the staleness threshold constant** + +In `internal/alert/engine.go`, add near the top of the file (after imports, before `JobFinishedEvent`): + +```go +// staleBackupThreshold is how long an intermittent host may go without +// a successful backup before we raise a stale_schedule alert. Global +// constant for v1 (may become per-host later). Only intermittent hosts +// are evaluated — always-on hosts' stale_schedule stays a no-op. +const staleBackupThreshold = 7 * 24 * time.Hour +``` + +- [ ] **Step 2: Suppress the offline alert for intermittent hosts** + +In `handleHostOffline` (line 155), after loading the host and the existing `if host.LastSeenAt == nil { return }` guard, add a mode check. Change: + +```go + if host.LastSeenAt == nil { + return + } + if time.Since(*host.LastSeenAt) < e.agentOfflineFloor { + return + } +``` + +to: + +```go + // Intermittent hosts (laptops) legitimately disappear — never raise + // agent_offline for them. The stale_schedule sweep in tick() is the + // only staleness signal for these hosts. + if !host.AlwaysOn { + return + } + if host.LastSeenAt == nil { + return + } + if time.Since(*host.LastSeenAt) < e.agentOfflineFloor { + return + } +``` + +- [ ] **Step 3: Suppress offline + add staleness in the tick sweep** + +In `tick` (line 188), the host loop currently raises agent_offline for every offline host. Replace the loop body (lines 205-214) with: + +```go + for _, h := range hosts { + // Intermittent hosts: suppress agent_offline entirely; instead + // raise stale_schedule when they have gone too long with no + // successful backup AND they have at least one enabled schedule + // to be measured against. A nil LastBackupAt (never backed up) + // has no baseline — onboarding/repo_status covers that case. + if !h.AlwaysOn { + if h.LastBackupAt == nil { + continue + } + if now.Sub(*h.LastBackupAt) < staleBackupThreshold { + continue + } + hasEnabled, err := e.hostHasEnabledSchedule(ctx, h.ID) + if err != nil || !hasEnabled { + continue + } + e.raiseAndNotify(ctx, h.ID, KindStaleSchedule, "", "warning", + fmt.Sprintf("No backup in %s (threshold %s)", + roundDur(now.Sub(*h.LastBackupAt)), staleBackupThreshold), now) + continue + } + // Always-on hosts: existing agent_offline re-evaluation. + if h.Status != "offline" || h.LastSeenAt == nil { + continue + } + if now.Sub(*h.LastSeenAt) >= e.agentOfflineFloor { + e.raiseAndNotify(ctx, h.ID, KindAgentOffline, "", "warning", + fmt.Sprintf("Agent offline for %s (threshold %s)", + roundDur(now.Sub(*h.LastSeenAt)), e.agentOfflineFloor), now) + } + } +``` + +Delete the trailing `// Stale-schedule sweep — no-op in v1.` comment at line 215. + +- [ ] **Step 4: Add the `hostHasEnabledSchedule` helper** + +In `internal/alert/engine.go`, add at the end of the file: + +```go +// hostHasEnabledSchedule reports whether the host has at least one +// enabled backup schedule — the precondition for a stale_schedule +// alert (no schedule = no backup expectation to measure against). +func (e *Engine) hostHasEnabledSchedule(ctx context.Context, hostID string) (bool, error) { + schedules, err := e.store.ListSchedulesByHost(ctx, hostID) + if err != nil { + return false, err + } + for _, sc := range schedules { + if sc.Enabled { + return true, nil + } + } + return false, nil +} +``` + +- [ ] **Step 5: Resolve staleness on a successful backup** + +In `handleJobFinished` (line 146), the `case "succeeded":` currently resolves only the job-kind alert. For a successful backup, also clear any open stale_schedule. Change: + +```go + case "succeeded": + e.resolveAndNotify(ctx, ev.HostID, kind, dedupKey, ev.When) + } +``` + +to: + +```go + case "succeeded": + e.resolveAndNotify(ctx, ev.HostID, kind, dedupKey, ev.When) + if ev.Kind == "backup" { + // A fresh backup clears staleness for intermittent hosts. + e.resolveAndNotify(ctx, ev.HostID, KindStaleSchedule, "", ev.When) + } + } +``` + +- [ ] **Step 6: Add an exported mode-change resolve hook** + +The HTTP toggle handler (Task 5) needs to clear stale alerts when an operator changes a host's mode. Add to `internal/alert/rules.go` (after `Resolve`, around line 100): + +```go +// ResolveOnModeChange clears any open agent_offline and stale_schedule +// alerts for a host whose always-on flag was just toggled. The next +// 60s tick re-raises whichever still applies under the new mode, so +// this is a self-correcting "wipe and let the sweep settle" call. +// Safe to invoke from the HTTP layer (it only touches the store + hub). +func (e *Engine) ResolveOnModeChange(ctx context.Context, hostID string, when time.Time) { + e.resolveAndNotify(ctx, hostID, KindAgentOffline, "", when) + e.resolveAndNotify(ctx, hostID, KindStaleSchedule, "", when) +} +``` + +- [ ] **Step 7: Write the engine tests** + +Create `internal/alert/intermittent_test.go`. First inspect an existing engine test (e.g. grep `internal/alert/*_test.go` for how `NewEngine` is constructed with a test store + hub, and the helper that creates a host + schedule). Mirror those helpers. The tests to write: + +```go +package alert + +import ( + "context" + "testing" + "time" +) + +// Mirror the construction helpers used by the existing engine tests +// (newTestEngine / test store / host+schedule seeding). Replace the +// placeholder helpers below with the real ones from this package's +// existing _test.go files. + +func TestIntermittentHostSuppressesOfflineAlert(t *testing.T) { + ctx := context.Background() + e, st := newTestEngine(t) // mirror existing helper + + hostID := seedHost(t, st, false /* alwaysOn */) + // last seen well past the floor + touchHostSeen(t, st, hostID, time.Now().Add(-2*time.Hour)) + markHostOffline(t, st, hostID) + + e.handleHostOffline(ctx, hostID) + + if n := openAlertCount(t, st, hostID, KindAgentOffline); n != 0 { + t.Fatalf("intermittent host should not raise agent_offline, got %d", n) + } +} + +func TestAlwaysOnHostStillRaisesOfflineAlert(t *testing.T) { + ctx := context.Background() + e, st := newTestEngine(t) + + hostID := seedHost(t, st, true /* alwaysOn */) + touchHostSeen(t, st, hostID, time.Now().Add(-2*time.Hour)) + markHostOffline(t, st, hostID) + + e.handleHostOffline(ctx, hostID) + + if n := openAlertCount(t, st, hostID, KindAgentOffline); n != 1 { + t.Fatalf("always-on host should raise agent_offline, got %d", n) + } +} + +func TestStalenessAlertForIntermittentHost(t *testing.T) { + ctx := context.Background() + e, st := newTestEngine(t) + + hostID := seedHost(t, st, false) + seedEnabledSchedule(t, st, hostID) // "0 2 * * *" with a source group + setLastBackup(t, st, hostID, time.Now().Add(-8*24*time.Hour)) + + e.tick(ctx, time.Now().UTC()) + + if n := openAlertCount(t, st, hostID, KindStaleSchedule); n != 1 { + t.Fatalf("expected one stale_schedule alert, got %d", n) + } + + // A successful backup clears it. + e.handleJobFinished(ctx, JobFinishedEvent{ + HostID: hostID, JobID: "j1", Kind: "backup", + Status: "succeeded", When: time.Now().UTC(), + }) + if n := openAlertCount(t, st, hostID, KindStaleSchedule); n != 0 { + t.Fatalf("stale_schedule should resolve after backup, got %d", n) + } +} + +func TestNoStalenessWithoutEnabledSchedule(t *testing.T) { + ctx := context.Background() + e, st := newTestEngine(t) + + hostID := seedHost(t, st, false) + setLastBackup(t, st, hostID, time.Now().Add(-8*24*time.Hour)) + // no schedule seeded + + e.tick(ctx, time.Now().UTC()) + + if n := openAlertCount(t, st, hostID, KindStaleSchedule); n != 0 { + t.Fatalf("no schedule => no staleness alert, got %d", n) + } +} +``` + +> **Note for the implementer:** the `newTestEngine`, `seedHost`, `touchHostSeen`, `markHostOffline`, `openAlertCount`, `seedEnabledSchedule`, `setLastBackup` helpers must be replaced with the real equivalents in this package's existing tests. If a needed seeding helper doesn't exist, write it using the `store` methods directly (`CreateHost`, `SetHostAlwaysOn`, `CreateSchedule`, `SetHostLastBackup`, `MarkHostsOfflineStale`, `ListAlerts`). Do NOT invent store methods — all required ones exist as of Task 1. + +- [ ] **Step 8: Run the tests** + +Run: `go test ./internal/alert/ -v` +Expected: PASS for all four new tests plus the existing suite. + +- [ ] **Step 9: Commit** + +```bash +go vet ./internal/alert/... +git add internal/alert/engine.go internal/alert/rules.go internal/alert/intermittent_test.go +git commit -m "feat(alert): suppress offline + add staleness alert for intermittent hosts" +``` + +--- + +## Task 5: HTTP toggle handler + route + +**Files:** +- Modify: `internal/server/http/ui_handlers.go` (new handler near `handleUIHostTagsSave` at line 954) +- Modify: `internal/server/http/server.go:281` (route mount) + +- [ ] **Step 1: Add the handler** + +In `internal/server/http/ui_handlers.go`, after `handleUIHostTagsSave` (line 984), add: + +```go +// handleUIHostModeSave flips a host's always-on flag. Checkbox present +// in the form (value any) => always-on; absent => intermittent. +// Operator-band; mounted in server.go. On change we clear open +// offline/staleness alerts via the engine so the next sweep re-raises +// only what still applies under the new mode. +func (s *Server) handleUIHostModeSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + stdhttp.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + alwaysOn := r.PostForm.Get("always_on") != "" + if err := s.deps.Store.SetHostAlwaysOn(r.Context(), hostID, alwaysOn); err != nil { + slog.Error("ui host mode: save", "host_id", hostID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if s.deps.AlertEngine != nil { + s.deps.AlertEngine.ResolveOnModeChange(r.Context(), hostID, time.Now().UTC()) + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "host.mode_updated", + TargetKind: ptr("host"), TargetID: &hostID, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther) +} +``` + +- [ ] **Step 2: Mount the route** + +In `internal/server/http/server.go`, next to the tags route (line 281): + +```go + r.Post("/hosts/{id}/tags", s.handleUIHostTagsSave) +``` + +add directly below: + +```go + r.Post("/hosts/{id}/mode", s.handleUIHostModeSave) +``` + +(Confirm it lands in the same operator-band route group as `/hosts/{id}/tags` — same indentation/block.) + +- [ ] **Step 3: Build and vet** + +Run: `go build ./... && go vet ./...` +Expected: clean. + +- [ ] **Step 4: Write a handler test** + +Add to the existing UI-handler test file (grep `internal/server/http/*_test.go` for the harness that builds a `Server` + does form POSTs against `/hosts/{id}/tags`; mirror it). The test posts to `/hosts/{id}/mode` with and without the `always_on` field and asserts the stored flag: + +```go +func TestHandleUIHostModeSave(t *testing.T) { + srv, st, sess := newUITestServer(t) // mirror tags-save test harness + hostID := seedHostForUI(t, st) // mirror existing host seeding + + // Uncheck: form without always_on => intermittent. + postForm(t, srv, sess, "/hosts/"+hostID+"/mode", map[string]string{}) + if h, _ := st.GetHost(context.Background(), hostID); h.AlwaysOn { + t.Fatalf("expected always_on=false after empty post") + } + + // Check: form with always_on=on => always-on. + postForm(t, srv, sess, "/hosts/"+hostID+"/mode", map[string]string{"always_on": "on"}) + if h, _ := st.GetHost(context.Background(), hostID); !h.AlwaysOn { + t.Fatalf("expected always_on=true after checked post") + } +} +``` + +> Replace `newUITestServer`/`seedHostForUI`/`postForm` with the real harness helpers from the existing UI handler tests. + +- [ ] **Step 5: Run the test** + +Run: `go test ./internal/server/http/ -run TestHandleUIHostModeSave -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/http/ui_handlers.go internal/server/http/server.go internal/server/http/*_test.go +git commit -m "feat(http): host mode toggle handler + route (host.mode_updated)" +``` + +--- + +## Task 6: UI — asleep state, 24×7 chip, mode toggle + +**Files:** +- Modify: `web/styles/input.css` (dot-asleep token) +- Modify: `web/templates/partials/host_row.html` +- Modify: `web/templates/partials/host_chrome.html` + +- [ ] **Step 1: Add the `dot-asleep` CSS token** + +In `web/styles/input.css`, find the `.dot-offline` definition (grep for `dot-offline`) and add a sibling `.dot-asleep` rule. Match the existing dot pattern; use a calm grey-blue distinct from offline's grey/red. Example (adapt colours to the file's existing tokens): + +```css +.dot-asleep { background: var(--ink-fade); opacity: 0.6; } +``` + +> Inspect the neighbouring `.dot-offline` / `.dot-degraded` rules first and follow their exact shape (size, border, etc.); only the colour/opacity should differ. + +- [ ] **Step 2: Rebuild CSS if the project precompiles it** + +Check the Makefile for a CSS build step (grep `css` in `Makefile`). If present, run it (e.g. `make css`). If the server serves `input.css` directly, skip. + +- [ ] **Step 3: Asleep dot + text in host_row.html** + +In `web/templates/partials/host_row.html`, change the status-dot block (lines 6-14). Replace the `{{- else if eq $h.Status "offline" -}}` dot branch: + +```html + {{- else if eq $h.Status "offline" -}} + +``` + +with: + +```html + {{- else if eq $h.Status "offline" -}} + {{- if $h.AlwaysOn -}} + + {{- else -}} + + {{- end -}} +``` + +Then change the last-seen text branch (lines 28-29): + +```html + {{- else if eq $h.Status "offline" -}} + last seen {{relTime $h.LastSeenAt}} +``` + +to: + +```html + {{- else if eq $h.Status "offline" -}} + {{- if $h.AlwaysOn -}} + last seen {{relTime $h.LastSeenAt}} + {{- else -}} + asleep · {{relTime $h.LastSeenAt}} · will catch up on return + {{- end -}} +``` + +And the row-action label (lines 55-56): + +```html + {{- if eq $h.Status "offline" -}} + offline +``` + +to: + +```html + {{- if eq $h.Status "offline" -}} + {{if $h.AlwaysOn}}offline{{else}}asleep{{end}} +``` + +- [ ] **Step 4: Asleep dot + last-seen in host_chrome.html** + +In `web/templates/partials/host_chrome.html`, change the offline dot branch (lines 36-37): + +```html + {{else if eq $host.Status "offline"}} + +``` + +to: + +```html + {{else if eq $host.Status "offline"}} + {{if $host.AlwaysOn}} + + {{else}} + + {{end}} +``` + +And the last-seen line (lines 90-94): + +```html + {{if eq $host.Status "offline"}} + last seen {{relTime $host.LastSeenAt}} + {{else}} + online · last heartbeat {{relTime $host.LastSeenAt}} + {{end}} +``` + +to: + +```html + {{if eq $host.Status "offline"}} + {{if $host.AlwaysOn}} + last seen {{relTime $host.LastSeenAt}} + {{else}} + asleep · last seen {{relTime $host.LastSeenAt}} · will catch up on return + {{end}} + {{else}} + online · last heartbeat {{relTime $host.LastSeenAt}} + {{end}} +``` + +- [ ] **Step 5: Add the 24×7 chip + mode toggle to host_chrome.html** + +In the header tags block (lines 42-48), after the tags `edit/add tags` button and before the closing `` at line 48, add the chip (shown only when always-on) and a small toggle button mirroring the tags-editor reveal pattern: + +```html + {{if $host.AlwaysOn}}24×7{{end}} + +``` + +Then add the toggle form right after the tags `
` block (after line 82, before the `
` at line 83): + +```html + {{/* Presence-mode editor — hidden by default; toggled by the + "presence" button. Checkbox present => always-on (24×7); + unchecked => intermittent (laptop): no offline alerts, shows + "asleep", auto-catches-up a missed backup on reconnect. */}} + + +
+ Uncheck for an intermittent host (laptop/workstation): it won’t + raise offline alerts when asleep, shows an “asleep” state, and + catches up a missed backup ~1 minute after it reconnects. +
+ + +``` + +- [ ] **Step 6: Verify templates parse** + +Run: `go build ./... && go test ./internal/server/... -run Template -v` (if a template-render test exists; otherwise rely on the smoke run in Step 7). At minimum: `go build ./...` must pass. + +- [ ] **Step 7: Manual smoke (per CLAUDE.md smoke targets)** + +```bash +make smoke-deploy +``` + +Then in a browser (or Playwright): open the dashboard and a host detail page. Toggle a host to intermittent via the "presence" control, confirm the 24×7 chip disappears, and confirm an offline/sleeping intermittent host renders the grey "asleep · … · will catch up on return" line instead of red "offline". Toggle back and confirm the chip returns. + +- [ ] **Step 8: Commit** + +```bash +git add web/styles/input.css web/templates/partials/host_row.html web/templates/partials/host_chrome.html +git commit -m "feat(ui): asleep state, 24×7 chip, presence toggle for host mode" +``` + +--- + +## Task 7: Record in tasks.md + final verification + +**Files:** +- Modify: `tasks.md` + +- [ ] **Step 1: Add a tasks.md entry** + +Add a `[x]` entry under "Next steps from testing" in `tasks.md` (mirroring the NS-07 style — one line + a short "As shipped" note) describing the always-on/intermittent host mode: `always_on` column (default on), offline-alert suppression + 7-day staleness alert for intermittent hosts, settle-then-catch-up on reconnect, and the asleep UI + 24×7 chip + presence toggle. + +- [ ] **Step 2: Full verification** + +```bash +go vet ./... +go test ./... +``` + +Expected: vet clean, all tests green. + +- [ ] **Step 3: Commit** + +```bash +git add tasks.md +git commit -m "docs(tasks): record always-on/intermittent host mode" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** §1 data model → Task 1. §2 mechanics unchanged → no task needed (verified untouched). §3 alerts (suppress offline, staleness, resolve-on-backup, resolve-on-toggle) → Task 4 + Task 5 Step 1. §4 catch-up (arm on hello, settle, per-schedule overdue, dispatch, guards) → Tasks 2-3. §5 UI (dot-asleep, asleep text, 24×7 chip, toggle) → Task 6. Testing → tests in Tasks 1-5. Out-of-scope items respected (global 7d const, reconnect-only, no agent-side cron, always-on stale_schedule untouched). +- **Type consistency:** `scheduleOverdue(cronExpr string, *time.Time, time.Time) bool`, `ArmCatchup(hostID string, now time.Time)`, `RunCatchupsDue(ctx)`, `SetHostAlwaysOn(ctx, hostID, bool)`, `ResolveOnModeChange(ctx, hostID, when)`, `Host.AlwaysOn bool` — used consistently across tasks. +- **No invented store methods:** all `store.*` calls (GetHost, ListSchedulesByHost, GetSourceGroup, SetHostLastBackup, ListAlerts, AppendAudit, dispatchBackupForGroupCore, Hub.Conn/Connected) exist in the current tree; `SetHostAlwaysOn` is the only new one and is defined in Task 1. +- **Test helper caveat:** the alert and HTTP handler tests reference package-local helpers (`newTestEngine`, `newUITestServer`, etc.) that must be matched to the real names in existing `_test.go` files at implementation time — flagged inline in each task. From ff65d39f254497e9053c898d87083f6cca572cef Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 20:53:13 +0100 Subject: [PATCH 04/16] feat(store): add hosts.always_on flag (default on) --- internal/store/hosts.go | 26 +++++++++-- internal/store/hosts_always_on_test.go | 46 +++++++++++++++++++ .../store/migrations/0024_hosts_always_on.sql | 6 +++ internal/store/types.go | 6 +++ 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 internal/store/hosts_always_on_test.go create mode 100644 internal/store/migrations/0024_hosts_always_on.sql diff --git a/internal/store/hosts.go b/internal/store/hosts.go index 18d5c7d..8dad905 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -44,7 +44,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (* repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, pre_hook_default, post_hook_default, - repo_status, repo_status_error + repo_status, repo_status_error, always_on FROM hosts WHERE agent_token_hash = ?`, tokenHash) return scanHost(row) @@ -59,7 +59,7 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) { repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, pre_hook_default, post_hook_default, - repo_status, repo_status_error + repo_status, repo_status_error, always_on FROM hosts WHERE id = ?`, id) return scanHost(row) } @@ -227,7 +227,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, pre_hook_default, post_hook_default, - repo_status, repo_status_error + repo_status, repo_status_error, always_on FROM hosts ORDER BY name`) if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) @@ -267,6 +267,7 @@ func scanHostRow(s hostScanner) (*Host, error) { tags string bwUp, bwDown sql.NullInt64 preHook, postHook sql.NullString + alwaysOn int ) err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, @@ -275,7 +276,7 @@ func scanHostRow(s hostScanner) (*Host, error) { &h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount, &h.AppliedScheduleVersion, &bwUp, &bwDown, &preHook, &postHook, - &h.RepoStatus, &h.RepoStatusError) + &h.RepoStatus, &h.RepoStatusError, &alwaysOn) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -330,6 +331,7 @@ func scanHostRow(s hostScanner) (*Host, error) { if postHook.Valid { h.PostHookDefault = postHook.String } + h.AlwaysOn = alwaysOn != 0 return &h, nil } @@ -378,6 +380,22 @@ func (s *Store) SetHostTags(ctx context.Context, hostID string, tags []string) e return nil } +// SetHostAlwaysOn flips the host's always-on flag. true = 24x7 server +// (default); false = intermittent host (laptop). See the +// always-on-host-mode spec. +func (s *Store) SetHostAlwaysOn(ctx context.Context, hostID string, alwaysOn bool) error { + v := 0 + if alwaysOn { + v = 1 + } + _, err := s.db.ExecContext(ctx, + `UPDATE hosts SET always_on = ? WHERE id = ?`, v, hostID) + if err != nil { + return fmt.Errorf("store: set host always_on: %w", err) + } + return nil +} + // DistinctHostTags returns the union of every tag in use across the // fleet, sorted. Powers the autocomplete on the host-tags editor and // the chip-row filter on the dashboard. Cheap at fleet sizes this diff --git a/internal/store/hosts_always_on_test.go b/internal/store/hosts_always_on_test.go new file mode 100644 index 0000000..8f9cf04 --- /dev/null +++ b/internal/store/hosts_always_on_test.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestHostAlwaysOnDefaultAndToggle(t *testing.T) { + ctx := context.Background() + st := openTestStore(t) + + h := Host{ + ID: "h-always-on", Name: "lap", OS: "linux", Arch: "amd64", + ProtocolVersion: 1, EnrolledAt: time.Now().UTC(), + } + if err := st.CreateHost(ctx, h, "tok-hash", "pin"); err != nil { + t.Fatalf("create host: %v", err) + } + got, err := st.GetHost(ctx, h.ID) + if err != nil { + t.Fatalf("get host: %v", err) + } + if !got.AlwaysOn { + t.Fatalf("new host should default to always_on=true, got false") + } + + if err := st.SetHostAlwaysOn(ctx, h.ID, false); err != nil { + t.Fatalf("set always_on: %v", err) + } + got, err = st.GetHost(ctx, h.ID) + if err != nil { + t.Fatalf("get host 2: %v", err) + } + if got.AlwaysOn { + t.Fatalf("expected always_on=false after toggle, got true") + } + + hosts, err := st.ListHosts(ctx) + if err != nil { + t.Fatalf("list hosts: %v", err) + } + if len(hosts) != 1 || hosts[0].AlwaysOn { + t.Fatalf("ListHosts should report always_on=false, got %+v", hosts) + } +} diff --git a/internal/store/migrations/0024_hosts_always_on.sql b/internal/store/migrations/0024_hosts_always_on.sql new file mode 100644 index 0000000..7165fa8 --- /dev/null +++ b/internal/store/migrations/0024_hosts_always_on.sql @@ -0,0 +1,6 @@ +-- 0024: distinguish always-on (24x7 server) hosts from intermittent +-- hosts (laptops/workstations that legitimately sleep). Default 1 so +-- every existing and future host keeps today's offline/alert +-- semantics unless explicitly opted out. Column-level ALTER per the +-- repo's migration rules (no table rebuild — hosts has inbound FKs). +ALTER TABLE hosts ADD COLUMN always_on INTEGER NOT NULL DEFAULT 1; diff --git a/internal/store/types.go b/internal/store/types.go index cc60e48..9ad6326 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -99,6 +99,12 @@ type Host struct { // agent-side message when RepoStatus == "init_failed". RepoStatus string RepoStatusError string + + // AlwaysOn is true for 24x7 server hosts (the default). When false + // the host is intermittent (laptop/workstation): offline alerts are + // suppressed, the UI shows an "asleep" state, and a missed backup is + // caught up ~1 min after reconnect. See the always-on-host-mode spec. + AlwaysOn bool } // Schedule is now intentionally slim: cron + which groups + enabled. From 4c9641b6edddc1d5a603a670e1492bb08ff933f5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 20:56:59 +0100 Subject: [PATCH 05/16] fix(store): SetHostAlwaysOn returns ErrNotFound; test agent-token lookup path --- internal/store/hosts.go | 5 ++++- internal/store/hosts_always_on_test.go | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/store/hosts.go b/internal/store/hosts.go index 8dad905..9afa5c7 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -388,11 +388,14 @@ func (s *Store) SetHostAlwaysOn(ctx context.Context, hostID string, alwaysOn boo if alwaysOn { v = 1 } - _, err := s.db.ExecContext(ctx, + res, err := s.db.ExecContext(ctx, `UPDATE hosts SET always_on = ? WHERE id = ?`, v, hostID) if err != nil { return fmt.Errorf("store: set host always_on: %w", err) } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } return nil } diff --git a/internal/store/hosts_always_on_test.go b/internal/store/hosts_always_on_test.go index 8f9cf04..72063ae 100644 --- a/internal/store/hosts_always_on_test.go +++ b/internal/store/hosts_always_on_test.go @@ -43,4 +43,13 @@ func TestHostAlwaysOnDefaultAndToggle(t *testing.T) { if len(hosts) != 1 || hosts[0].AlwaysOn { t.Fatalf("ListHosts should report always_on=false, got %+v", hosts) } + + // Verify the agent hot-path (LookupHostByAgentToken) also reflects the toggle. + byToken, err := st.LookupHostByAgentToken(ctx, "tok-hash") + if err != nil { + t.Fatalf("lookup by agent token: %v", err) + } + if byToken.AlwaysOn { + t.Fatalf("LookupHostByAgentToken: expected always_on=false after toggle, got true") + } } From 7aaafceab5f790270363225a3512eace191e4f4d Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 20:58:17 +0100 Subject: [PATCH 06/16] feat(catchup): scheduleOverdue helper for missed-window detection --- internal/server/http/catchup.go | 29 ++++++++++++++++++++ internal/server/http/catchup_test.go | 41 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 internal/server/http/catchup.go create mode 100644 internal/server/http/catchup_test.go diff --git a/internal/server/http/catchup.go b/internal/server/http/catchup.go new file mode 100644 index 0000000..15ae429 --- /dev/null +++ b/internal/server/http/catchup.go @@ -0,0 +1,29 @@ +// catchup.go — server-side catch-up for intermittent (non-always-on) +// hosts. When such a host reconnects we wait a short settle window, +// then dispatch a backup for any schedule whose window elapsed while +// the host was asleep. This is separate from pending_runs: a host that +// was asleep never fired its local cron, so no pending row exists. +package http + +import ( + "time" +) + +// scheduleOverdue reports whether a schedule's most recent expected +// fire is newer than the host's last successful backup — i.e. a window +// passed with no backup. A nil lastBackup means "never backed up" and +// is always overdue (provided the cron parses). An unparseable cron is +// treated as not-overdue so a bad expression can never trigger a +// surprise dispatch. Uses the same cronParser the agent's scheduler +// and schedule validation use, so interpretation is identical. +func scheduleOverdue(cronExpr string, lastBackup *time.Time, now time.Time) bool { + sched, err := cronParser.Parse(cronExpr) + if err != nil { + return false + } + if lastBackup == nil { + return true + } + next := sched.Next(*lastBackup) + return !next.After(now) +} diff --git a/internal/server/http/catchup_test.go b/internal/server/http/catchup_test.go new file mode 100644 index 0000000..a9de720 --- /dev/null +++ b/internal/server/http/catchup_test.go @@ -0,0 +1,41 @@ +package http + +import ( + "testing" + "time" +) + +func TestScheduleOverdue(t *testing.T) { + mustParse := func(s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return v + } + daily := "0 2 * * *" // 02:00 every day + + cases := []struct { + name string + cron string + lastBackup *time.Time + now time.Time + want bool + }{ + {name: "never backed up is overdue", cron: daily, lastBackup: nil, now: mustParse("2026-06-15T09:00:00Z"), want: true}, + {name: "missed last nights window", cron: daily, lastBackup: ptrTime(mustParse("2026-06-13T02:05:00Z")), now: mustParse("2026-06-15T09:00:00Z"), want: true}, + {name: "backed up after the most recent window", cron: daily, lastBackup: ptrTime(mustParse("2026-06-15T02:05:00Z")), now: mustParse("2026-06-15T09:00:00Z"), want: false}, + {name: "unparseable cron is never overdue", cron: "not a cron", lastBackup: nil, now: mustParse("2026-06-15T09:00:00Z"), want: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := scheduleOverdue(c.cron, c.lastBackup, c.now) + if got != c.want { + t.Fatalf("scheduleOverdue(%q, %v, %v) = %v, want %v", c.cron, c.lastBackup, c.now, got, c.want) + } + }) + } +} + +func ptrTime(t time.Time) *time.Time { return &t } From 5c4e0275d9c5c88a4090f952f88e6ddc54cae85b Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:02:04 +0100 Subject: [PATCH 07/16] feat(catchup): arm on hello, fire missed-window backups on tick --- cmd/server/main.go | 1 + internal/server/http/catchup.go | 98 ++++++++++++++++++++++++ internal/server/http/host_credentials.go | 6 ++ internal/server/http/server.go | 18 +++-- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1cd5151..ccb962b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -227,6 +227,7 @@ func run() error { } case <-pendingDrainTick.C: srv.DrainAllDue(ctx) + srv.RunCatchupsDue(ctx) case <-pendingExpiryTick.C: if n, err := st.DeleteExpiredPendingHosts(ctx, time.Now().UTC()); err == nil && n > 0 { slog.Info("expired pending hosts swept", "n", n) diff --git a/internal/server/http/catchup.go b/internal/server/http/catchup.go index 15ae429..68f7d92 100644 --- a/internal/server/http/catchup.go +++ b/internal/server/http/catchup.go @@ -6,6 +6,8 @@ package http import ( + "context" + "log/slog" "time" ) @@ -27,3 +29,99 @@ func scheduleOverdue(cronExpr string, lastBackup *time.Time, now time.Time) bool next := sched.Next(*lastBackup) return !next.After(now) } + +// catchupSettle is how long after a reconnect we wait before evaluating +// catch-up, so a laptop that wakes briefly and sleeps again doesn't +// trigger a backup it can't finish. ~1 minute per the spec. +const catchupSettle = 60 * time.Second + +// ArmCatchup records that an intermittent host just reconnected and +// should be evaluated for a missed backup after the settle window. +// No-op for always-on hosts (caller passes only intermittent hosts). +// Re-arming overwrites the timer (debounce — flapping doesn't stack). +func (s *Server) ArmCatchup(hostID string, now time.Time) { + s.catchupMu.Lock() + defer s.catchupMu.Unlock() + if s.catchupDueAt == nil { + s.catchupDueAt = make(map[string]time.Time) + } + s.catchupDueAt[hostID] = now.Add(catchupSettle) +} + +// dueCatchups returns the hostIDs whose settle window has elapsed and +// removes them from the map. Caller evaluates each. +func (s *Server) dueCatchups(now time.Time) []string { + s.catchupMu.Lock() + defer s.catchupMu.Unlock() + var due []string + for id, at := range s.catchupDueAt { + if !now.Before(at) { + due = append(due, id) + delete(s.catchupDueAt, id) + } + } + return due +} + +// RunCatchupsDue is the tick entrypoint. For each host past its settle +// window it dispatches a backup for every enabled schedule that is +// overdue. Skips hosts that bounced back offline, that are already +// running/queued a job, or that turned out to be always-on. +func (s *Server) RunCatchupsDue(ctx context.Context) { + if s.deps.Hub == nil { + return + } + now := time.Now().UTC() + for _, hostID := range s.dueCatchups(now) { + s.runCatchup(ctx, hostID, now) + } +} + +// runCatchup evaluates and dispatches catch-up backups for a single +// host. Kept separate so RunCatchupsDue reads cleanly. +func (s *Server) runCatchup(ctx context.Context, hostID string, now time.Time) { + conn := s.deps.Hub.Conn(hostID) + if conn == nil { + return // bounced offline during the settle window; re-arms on next hello + } + host, err := s.deps.Store.GetHost(ctx, hostID) + if err != nil { + slog.Warn("catchup: load host", "host_id", hostID, "err", err) + return + } + if host.AlwaysOn { + return // mode flipped during settle window + } + if host.CurrentJobID != nil { + return // a job is already running; don't pile on + } + schedules, err := s.deps.Store.ListSchedulesByHost(ctx, hostID) + if err != nil { + slog.Warn("catchup: list schedules", "host_id", hostID, "err", err) + return + } + for _, sc := range schedules { + if !sc.Enabled || len(sc.SourceGroupIDs) == 0 { + continue + } + if !scheduleOverdue(sc.CronExpr, host.LastBackupAt, now) { + continue + } + for _, gid := range sc.SourceGroupIDs { + g, err := s.deps.Store.GetSourceGroup(ctx, hostID, gid) + if err != nil { + slog.Warn("catchup: load source group", + "host_id", hostID, "schedule_id", sc.ID, "group_id", gid, "err", err) + continue + } + if _, derr := s.dispatchBackupForGroupCore(ctx, conn, hostID, sc.ID, g, now); derr != nil { + // Send failed — host dropped again. Re-arm so the next + // reconnect retries; stop processing this host. + s.ArmCatchup(hostID, now) + return + } + slog.Info("catchup: dispatched missed backup", + "host_id", hostID, "schedule_id", sc.ID, "group", g.Name) + } + } +} diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index ed8b504..5a52f4e 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -483,6 +483,12 @@ func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn) // and the drain may take seconds across many rows. A non-blocking // goroutine keeps the hello path snappy. go s.DrainPending(context.Background(), hostID) + // Intermittent hosts that just reconnected may have slept through a + // backup window. Arm a catch-up evaluation after a settle delay; the + // pending-drain tick fires it. Always-on hosts never need this. + if host, err := s.deps.Store.GetHost(ctx, hostID); err == nil && !host.AlwaysOn { + s.ArmCatchup(hostID, time.Now().UTC()) + } } // maybeAutoInit dispatches a `restic init` job iff the host has no diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 7d79cbf..15f8473 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -90,6 +90,13 @@ type Server struct { // directories (P3-X2). Pre-allocated in New so the lazy-init // race is impossible. treeCache *treeCache + + // catchupDueAt tracks intermittent hosts that reconnected and are + // in their settle window. Keyed hostID → earliest time to evaluate + // catch-up. Best-effort + in-memory: a server restart simply re-arms + // on the next hello. Guarded by catchupMu. + catchupMu sync.Mutex + catchupDueAt map[string]time.Time } // New builds a configured but not-yet-started server. @@ -104,11 +111,12 @@ func New(deps Deps) *Server { r.Use(requestLogger) s := &Server{ - deps: deps, - drainLocks: make(map[string]*sync.Mutex), - announceRL: newAnnounceLimiter(), - pendingHub: newPendingHub(), - treeCache: newTreeCache(), + deps: deps, + drainLocks: make(map[string]*sync.Mutex), + announceRL: newAnnounceLimiter(), + pendingHub: newPendingHub(), + treeCache: newTreeCache(), + catchupDueAt: make(map[string]time.Time), } s.routes(r) From e408de961019ff5573320fe221ca45af8a36fb30 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:06:37 +0100 Subject: [PATCH 08/16] refactor(catchup): drop dead nil-guard; document per-host baseline limitation --- internal/server/http/catchup.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/server/http/catchup.go b/internal/server/http/catchup.go index 68f7d92..9c67def 100644 --- a/internal/server/http/catchup.go +++ b/internal/server/http/catchup.go @@ -42,9 +42,6 @@ const catchupSettle = 60 * time.Second func (s *Server) ArmCatchup(hostID string, now time.Time) { s.catchupMu.Lock() defer s.catchupMu.Unlock() - if s.catchupDueAt == nil { - s.catchupDueAt = make(map[string]time.Time) - } s.catchupDueAt[hostID] = now.Add(catchupSettle) } @@ -100,6 +97,13 @@ func (s *Server) runCatchup(ctx context.Context, hostID string, now time.Time) { slog.Warn("catchup: list schedules", "host_id", hostID, "err", err) return } + // NOTE: overdue is measured against host.LastBackupAt, which is the + // most recent *successful backup of any schedule* on this host — not + // a per-schedule timestamp. For the common intermittent host (a + // single backup schedule) this is exact. With multiple schedules of + // different cadences, a recent backup from one schedule can mask + // another schedule's missed window. Acceptable for v1; revisit with + // per-schedule last-success tracking if multi-cadence laptops appear. for _, sc := range schedules { if !sc.Enabled || len(sc.SourceGroupIDs) == 0 { continue @@ -115,8 +119,10 @@ func (s *Server) runCatchup(ctx context.Context, hostID string, now time.Time) { continue } if _, derr := s.dispatchBackupForGroupCore(ctx, conn, hostID, sc.ID, g, now); derr != nil { - // Send failed — host dropped again. Re-arm so the next - // reconnect retries; stop processing this host. + // Send failed for this group — host may have dropped + // again. Earlier groups in this batch were already + // dispatched; re-arm so a later reconnect re-evaluates + // any still-overdue schedules. s.ArmCatchup(hostID, now) return } From 25c55e5e4dc36ed07b4ae940f67ccd8f827a7f08 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:09:39 +0100 Subject: [PATCH 09/16] feat(alert): suppress offline + add staleness alert for intermittent hosts --- internal/alert/engine.go | 55 ++++++++- internal/alert/intermittent_test.go | 172 ++++++++++++++++++++++++++++ internal/alert/rules.go | 10 ++ 3 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 internal/alert/intermittent_test.go diff --git a/internal/alert/engine.go b/internal/alert/engine.go index 607ed91..21c591b 100644 --- a/internal/alert/engine.go +++ b/internal/alert/engine.go @@ -22,6 +22,12 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) +// staleBackupThreshold is how long an intermittent host may go without +// a successful backup before we raise a stale_schedule alert. Global +// constant for v1 (may become per-host later). Only intermittent hosts +// are evaluated — always-on hosts' stale_schedule stays a no-op. +const staleBackupThreshold = 7 * 24 * time.Hour + // JobFinishedEvent carries everything the engine needs to evaluate // the failed-X rules. Pushed via Engine.NotifyJobFinished from the // MarkJobFinished site. @@ -149,6 +155,10 @@ func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) { fmt.Sprintf("%s job %s failed", ev.Kind, ev.JobID), ev.When) case "succeeded": e.resolveAndNotify(ctx, ev.HostID, kind, dedupKey, ev.When) + if ev.Kind == "backup" { + // A fresh backup clears staleness for intermittent hosts. + e.resolveAndNotify(ctx, ev.HostID, KindStaleSchedule, "", ev.When) + } } } @@ -157,6 +167,12 @@ func (e *Engine) handleHostOffline(ctx context.Context, hostID string) { if err != nil { return } + // Intermittent hosts (laptops) legitimately disappear — never raise + // agent_offline for them. The stale_schedule sweep in tick() is the + // only staleness signal for these hosts. + if !host.AlwaysOn { + return + } // Apply the 15-min floor — raise only when last_seen_at is older // than agentOfflineFloor. A nil last_seen_at (host enrolled but // never connected) is treated as "now" so we don't raise @@ -203,6 +219,28 @@ func (e *Engine) tick(ctx context.Context, now time.Time) { return } for _, h := range hosts { + // Intermittent hosts: suppress agent_offline entirely; instead + // raise stale_schedule when they have gone too long with no + // successful backup AND they have at least one enabled schedule + // to be measured against. A nil LastBackupAt (never backed up) + // has no baseline — onboarding/repo_status covers that case. + if !h.AlwaysOn { + if h.LastBackupAt == nil { + continue + } + if now.Sub(*h.LastBackupAt) < staleBackupThreshold { + continue + } + hasEnabled, err := e.hostHasEnabledSchedule(ctx, h.ID) + if err != nil || !hasEnabled { + continue + } + e.raiseAndNotify(ctx, h.ID, KindStaleSchedule, "", "warning", + fmt.Sprintf("No backup in %s (threshold %s)", + roundDur(now.Sub(*h.LastBackupAt)), staleBackupThreshold), now) + continue + } + // Always-on hosts: existing agent_offline re-evaluation. if h.Status != "offline" || h.LastSeenAt == nil { continue } @@ -212,7 +250,6 @@ func (e *Engine) tick(ctx context.Context, now time.Time) { roundDur(now.Sub(*h.LastSeenAt)), e.agentOfflineFloor), now) } } - // Stale-schedule sweep — no-op in v1. See KindStaleSchedule doc comment. } // roundDur returns a human-readable duration string, rounding to the @@ -224,3 +261,19 @@ func roundDur(d time.Duration) string { } return d.Round(time.Minute).String() } + +// hostHasEnabledSchedule reports whether the host has at least one +// enabled backup schedule — the precondition for a stale_schedule +// alert (no schedule = no backup expectation to measure against). +func (e *Engine) hostHasEnabledSchedule(ctx context.Context, hostID string) (bool, error) { + schedules, err := e.store.ListSchedulesByHost(ctx, hostID) + if err != nil { + return false, err + } + for _, sc := range schedules { + if sc.Enabled { + return true, nil + } + } + return false, nil +} diff --git a/internal/alert/intermittent_test.go b/internal/alert/intermittent_test.go new file mode 100644 index 0000000..fd316f5 --- /dev/null +++ b/internal/alert/intermittent_test.go @@ -0,0 +1,172 @@ +package alert + +import ( + "context" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// TestIntermittentHostSuppressesOfflineAlert checks that handleHostOffline +// does NOT raise agent_offline for a host with AlwaysOn=false. +func TestIntermittentHostSuppressesOfflineAlert(t *testing.T) { + t.Parallel() + eng, st, hostID := setupEngine(t) + ctx := context.Background() + + // Make the host intermittent. + if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil { + t.Fatalf("SetHostAlwaysOn: %v", err) + } + + // Give it a stale last_seen_at well past the floor. + if _, err := st.DB().Exec( + `UPDATE hosts SET last_seen_at = ?, status = ? WHERE id = ?`, + time.Now().UTC().Add(-2*time.Hour).Format(time.RFC3339Nano), + "offline", + hostID, + ); err != nil { + t.Fatalf("update last_seen_at: %v", err) + } + + eng.handleHostOffline(ctx, hostID) + + open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + if len(open) != 0 { + t.Fatalf("expected 0 open alerts for intermittent host; got %d: %+v", len(open), open) + } +} + +// TestAlwaysOnHostStillRaisesOfflineAlert checks that always-on hosts still +// get an agent_offline alert when offline past the floor. +func TestAlwaysOnHostStillRaisesOfflineAlert(t *testing.T) { + t.Parallel() + eng, st, hostID := setupEngine(t) + ctx := context.Background() + + // always_on=true is the default, but be explicit. + if err := st.SetHostAlwaysOn(ctx, hostID, true); err != nil { + t.Fatalf("SetHostAlwaysOn: %v", err) + } + + // Give it a stale last_seen_at well past the 15m floor. + if _, err := st.DB().Exec( + `UPDATE hosts SET last_seen_at = ?, status = ? WHERE id = ?`, + time.Now().UTC().Add(-2*time.Hour).Format(time.RFC3339Nano), + "offline", + hostID, + ); err != nil { + t.Fatalf("update last_seen_at: %v", err) + } + + eng.handleHostOffline(ctx, hostID) + + open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + if len(open) != 1 || open[0].Kind != KindAgentOffline { + t.Fatalf("expected 1 agent_offline alert; got %d: %+v", len(open), open) + } +} + +// TestStalenessAlertForIntermittentHost checks that tick raises stale_schedule +// for an intermittent host whose last backup is older than 7 days AND has an +// enabled schedule. Also verifies that a succeeded backup clears the alert. +func TestStalenessAlertForIntermittentHost(t *testing.T) { + t.Parallel() + eng, st, hostID := setupEngine(t) + ctx := context.Background() + + // Make intermittent. + if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil { + t.Fatalf("SetHostAlwaysOn: %v", err) + } + + // Create a source group to attach the schedule to. + sgID := ulid.Make().String() + if err := st.CreateSourceGroup(ctx, &store.SourceGroup{ + ID: sgID, + HostID: hostID, + Name: "default", + Includes: []string{"/home"}, + }); err != nil { + t.Fatalf("CreateSourceGroup: %v", err) + } + + // Create an enabled schedule pointing at the source group. + schedID := ulid.Make().String() + if err := st.CreateSchedule(ctx, &store.Schedule{ + ID: schedID, + HostID: hostID, + CronExpr: "0 2 * * *", + Enabled: true, + SourceGroupIDs: []string{sgID}, + }); err != nil { + t.Fatalf("CreateSchedule: %v", err) + } + + // Set last_backup_at to 8 days ago. + eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour) + if err := st.SetHostLastBackup(ctx, hostID, "succeeded", eightDaysAgo); err != nil { + t.Fatalf("SetHostLastBackup: %v", err) + } + + eng.tick(ctx, time.Now().UTC()) + + open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + var staleCount int + for _, a := range open { + if a.Kind == KindStaleSchedule { + staleCount++ + } + } + if staleCount != 1 { + t.Fatalf("expected 1 stale_schedule alert after tick; got %d (all open: %+v)", staleCount, open) + } + + // A succeeded backup should clear the stale_schedule alert. + eng.handleJobFinished(ctx, JobFinishedEvent{ + HostID: hostID, + JobID: ulid.Make().String(), + Kind: "backup", + Status: "succeeded", + SourceGroupID: sgID, + When: time.Now().UTC(), + }) + + open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + for _, a := range open { + if a.Kind == KindStaleSchedule { + t.Fatalf("expected stale_schedule to be resolved after backup succeeded; still open: %+v", a) + } + } +} + +// TestNoStalenessWithoutEnabledSchedule checks that no stale_schedule is +// raised for an intermittent host with a stale backup but no enabled schedule. +func TestNoStalenessWithoutEnabledSchedule(t *testing.T) { + t.Parallel() + eng, st, hostID := setupEngine(t) + ctx := context.Background() + + // Make intermittent. + if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil { + t.Fatalf("SetHostAlwaysOn: %v", err) + } + + // Set last_backup_at to 8 days ago — stale — but no schedule. + eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour) + if err := st.SetHostLastBackup(ctx, hostID, "succeeded", eightDaysAgo); err != nil { + t.Fatalf("SetHostLastBackup: %v", err) + } + + eng.tick(ctx, time.Now().UTC()) + + open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + for _, a := range open { + if a.Kind == KindStaleSchedule { + t.Fatalf("expected no stale_schedule without an enabled schedule; got: %+v", a) + } + } +} diff --git a/internal/alert/rules.go b/internal/alert/rules.go index 54e2015..d44daeb 100644 --- a/internal/alert/rules.go +++ b/internal/alert/rules.go @@ -122,6 +122,16 @@ func alertPayload(ctx context.Context, st *store.Store, ev notification.Event, a } } +// ResolveOnModeChange clears any open agent_offline and stale_schedule +// alerts for a host whose always-on flag was just toggled. The next +// 60s tick re-raises whichever still applies under the new mode, so +// this is a self-correcting "wipe and let the sweep settle" call. +// Safe to invoke from the HTTP layer (it only touches the store + hub). +func (e *Engine) ResolveOnModeChange(ctx context.Context, hostID string, when time.Time) { + e.resolveAndNotify(ctx, hostID, KindAgentOffline, "", when) + e.resolveAndNotify(ctx, hostID, KindStaleSchedule, "", when) +} + // resolveAndNotify clears the open (or acknowledged) alert matching // (host_id, kind, dedup_key) via store.AutoResolve, then fires // alert.resolved for the row(s) actually closed. Best-effort — From 9e6524788f4a2470ea6b222cc88b385dd54f4426 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:15:35 +0100 Subject: [PATCH 10/16] refactor(alert): refresh stale_schedule docs; log tick schedule errors; add mode-change + never-backed-up tests --- internal/alert/engine.go | 14 ++--- internal/alert/intermittent_test.go | 83 +++++++++++++++++++++++++++++ internal/alert/rules.go | 8 +-- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/internal/alert/engine.go b/internal/alert/engine.go index 21c591b..0f94b5a 100644 --- a/internal/alert/engine.go +++ b/internal/alert/engine.go @@ -196,11 +196,9 @@ func (e *Engine) handleHostOnline(ctx context.Context, hostID string) { // tick is the 60-second sweep. Responsibilities: // 1. Re-evaluate agent_offline for every offline host that may have // crossed the floor between explicit events. -// 2. Stale-schedule detection — declared in the spec but intentionally -// left as a no-op in v1. The precise "expected to have fired but -// didn't" trigger requires a store helper that lands in a later -// task. The KindStaleSchedule constant is exported so UI code can -// reference the tag string today. +// 2. Stale-schedule detection for intermittent hosts — raises +// stale_schedule when LastBackupAt is older than 7 days and the +// host has an enabled schedule. Always-on hosts are excluded. func (e *Engine) tick(ctx context.Context, now time.Time) { // User-management cleanup piggy-backed here for now. Setup tokens // have a 1h expiry; the alert engine tick is the cheapest existing @@ -232,7 +230,11 @@ func (e *Engine) tick(ctx context.Context, now time.Time) { continue } hasEnabled, err := e.hostHasEnabledSchedule(ctx, h.ID) - if err != nil || !hasEnabled { + if err != nil { + slog.Warn("alert: tick list schedules", "host_id", h.ID, "err", err) + continue + } + if !hasEnabled { continue } e.raiseAndNotify(ctx, h.ID, KindStaleSchedule, "", "warning", diff --git a/internal/alert/intermittent_test.go b/internal/alert/intermittent_test.go index fd316f5..d741831 100644 --- a/internal/alert/intermittent_test.go +++ b/internal/alert/intermittent_test.go @@ -170,3 +170,86 @@ func TestNoStalenessWithoutEnabledSchedule(t *testing.T) { } } } + +// TestResolveOnModeChangeClearsOfflineAlert checks that ResolveOnModeChange +// clears an open agent_offline alert when a host's mode is toggled. +func TestResolveOnModeChangeClearsOfflineAlert(t *testing.T) { + t.Parallel() + eng, st, hostID := setupEngine(t) + ctx := context.Background() + + // Make always-on and set it offline with a stale last_seen_at. + if err := st.SetHostAlwaysOn(ctx, hostID, true); err != nil { + t.Fatalf("SetHostAlwaysOn: %v", err) + } + if _, err := st.DB().Exec( + `UPDATE hosts SET last_seen_at = ?, status = ? WHERE id = ?`, + time.Now().UTC().Add(-2*time.Hour).Format(time.RFC3339Nano), + "offline", + hostID, + ); err != nil { + t.Fatalf("update last_seen_at: %v", err) + } + + // Raise the offline alert. + eng.handleHostOffline(ctx, hostID) + + open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + if len(open) != 1 || open[0].Kind != KindAgentOffline { + t.Fatalf("expected 1 agent_offline alert before mode change; got %d: %+v", len(open), open) + } + + // Toggle mode — should clear the alert. + eng.ResolveOnModeChange(ctx, hostID, time.Now().UTC()) + + open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + for _, a := range open { + if a.Kind == KindAgentOffline { + t.Fatalf("expected agent_offline to be resolved after mode change; still open: %+v", a) + } + } +} + +// TestNoStalenessWhenNeverBackedUp checks that no stale_schedule alert is +// raised for an intermittent host that has never backed up (nil LastBackupAt). +func TestNoStalenessWhenNeverBackedUp(t *testing.T) { + t.Parallel() + eng, st, hostID := setupEngine(t) + ctx := context.Background() + + // Make intermittent. + if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil { + t.Fatalf("SetHostAlwaysOn: %v", err) + } + + // Create a source group and an enabled schedule — but do NOT set LastBackupAt. + sgID := ulid.Make().String() + if err := st.CreateSourceGroup(ctx, &store.SourceGroup{ + ID: sgID, + HostID: hostID, + Name: "default", + Includes: []string{"/home"}, + }); err != nil { + t.Fatalf("CreateSourceGroup: %v", err) + } + + schedID := ulid.Make().String() + if err := st.CreateSchedule(ctx, &store.Schedule{ + ID: schedID, + HostID: hostID, + CronExpr: "0 2 * * *", + Enabled: true, + SourceGroupIDs: []string{sgID}, + }); err != nil { + t.Fatalf("CreateSchedule: %v", err) + } + + eng.tick(ctx, time.Now().UTC()) + + open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID}) + for _, a := range open { + if a.Kind == KindStaleSchedule { + t.Fatalf("expected no stale_schedule when never backed up; got: %+v", a) + } + } +} diff --git a/internal/alert/rules.go b/internal/alert/rules.go index d44daeb..f5e779c 100644 --- a/internal/alert/rules.go +++ b/internal/alert/rules.go @@ -27,10 +27,10 @@ const ( // integrity is at risk) when a check job fails. KindCheckFailed = "check_failed" - // KindStaleSchedule is declared for completeness but intentionally - // left as a no-op in v1. The precise "expected to have fired but - // didn't" logic requires a store helper that lands in a follow-up - // task. Ask the team before implementing. + // KindStaleSchedule is raised for intermittent (non-always-on) hosts + // when their last successful backup is older than staleBackupThreshold + // (7 days) and they have at least one enabled schedule. Resolved on + // backup success or when the host is switched to always-on mode. KindStaleSchedule = "stale_schedule" // KindAgentOffline is raised when a host's last_seen_at is older From 1a07fbb21732da9ccc8c0a06354a1cb3ce4b9075 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:17:57 +0100 Subject: [PATCH 11/16] feat(http): host mode toggle handler + route (host.mode_updated) --- internal/server/http/server.go | 1 + internal/server/http/ui_handlers.go | 37 ++++++++++ internal/server/http/ui_host_mode_test.go | 88 +++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 internal/server/http/ui_host_mode_test.go diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 15f8473..465b1b2 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -287,6 +287,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/repo/probe", s.handleUIRepoProbe) r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave) r.Post("/hosts/{id}/tags", s.handleUIHostTagsSave) + r.Post("/hosts/{id}/mode", s.handleUIHostModeSave) r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index e0c4515..d2e1d98 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -983,6 +983,43 @@ func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Reque stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther) } +// handleUIHostModeSave flips a host's always-on flag. Checkbox present +// in the form (value any) => always-on; absent => intermittent. +// Operator-band; mounted in server.go. On change we clear open +// offline/staleness alerts via the engine so the next sweep re-raises +// only what still applies under the new mode. +func (s *Server) handleUIHostModeSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + hostID := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + stdhttp.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + alwaysOn := r.PostForm.Get("always_on") != "" + if err := s.deps.Store.SetHostAlwaysOn(r.Context(), hostID, alwaysOn); err != nil { + slog.Error("ui host mode: save", "host_id", hostID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if s.deps.AlertEngine != nil { + s.deps.AlertEngine.ResolveOnModeChange(r.Context(), hostID, time.Now().UTC()) + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "host.mode_updated", + TargetKind: ptr("host"), TargetID: &hostID, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther) +} + // normaliseTags splits a comma-separated string, lowercases each token, // trims whitespace, drops empties, and dedupes. Order is preserved // from first occurrence (so the user's typing order shows on screen). diff --git a/internal/server/http/ui_host_mode_test.go b/internal/server/http/ui_host_mode_test.go new file mode 100644 index 0000000..b23ca02 --- /dev/null +++ b/internal/server/http/ui_host_mode_test.go @@ -0,0 +1,88 @@ +// ui_host_mode_test.go — covers handleUIHostModeSave: toggling a +// host's always-on flag via POST /hosts/{id}/mode. +package http + +import ( + "context" + stdhttp "net/http" + "net/url" + "strings" + "testing" +) + +// TestHostModeSaveToggle verifies the checkbox-absent ⇒ intermittent +// and checkbox-present ⇒ always-on semantics, and that the audit row +// lands for each request. +func TestHostModeSaveToggle(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, nil, st, "mode-toggle-host") + + cookie := loginAsAdmin(t, st) + + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + + // --- POST with no always_on field => intermittent --- + form := url.Values{} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/mode", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + _ = res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res.StatusCode) + } + if loc := res.Header.Get("Location"); loc != "/hosts/"+hostID { + t.Errorf("Location: got %q, want /hosts/%s", loc, hostID) + } + + got, err := st.GetHost(context.Background(), hostID) + if err != nil { + t.Fatalf("GetHost: %v", err) + } + if got.AlwaysOn { + t.Errorf("AlwaysOn after empty form: got true, want false") + } + + // --- POST with always_on=on => always-on --- + form2 := url.Values{"always_on": {"on"}} + req2, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/mode", + strings.NewReader(form2.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req2.AddCookie(cookie) + res2, err := cli.Do(req2) + if err != nil { + t.Fatalf("do: %v", err) + } + _ = res2.Body.Close() + if res2.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res2.StatusCode) + } + + got2, err := st.GetHost(context.Background(), hostID) + if err != nil { + t.Fatalf("GetHost: %v", err) + } + if !got2.AlwaysOn { + t.Errorf("AlwaysOn after always_on=on: got false, want true") + } + + // Audit rows must exist (one per request). + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'host.mode_updated' AND target_id = ?`, + hostID).Scan(&n); err != nil { + t.Fatalf("count audit: %v", err) + } + if n != 2 { + t.Errorf("audit rows: got %d, want 2", n) + } +} From f88f2cc1f2e0e8075a7bd9dda79670f945ef3fee Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:22:42 +0100 Subject: [PATCH 12/16] =?UTF-8?q?feat(ui):=20asleep=20state,=2024=C3=977?= =?UTF-8?q?=20chip,=20presence=20toggle=20for=20host=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/styles/input.css | 1 + web/templates/partials/host_chrome.html | 35 +++++++++++++++++++++++-- web/templates/partials/host_row.html | 14 +++++++--- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/web/styles/input.css b/web/styles/input.css index dd1a861..5e1835b 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -70,6 +70,7 @@ .dot-online { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ok), transparent 80%); } .dot-degraded { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn), transparent 80%); } .dot-offline { background: var(--off); } + .dot-asleep { background: var(--ink-fade); opacity: 0.6; } .dot-failed { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in oklch, var(--bad), transparent 80%); } .pulse { animation: rm-pulse 2.4s ease-in-out infinite; } @keyframes rm-pulse { diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html index 78a85bd..6bc165e 100644 --- a/web/templates/partials/host_chrome.html +++ b/web/templates/partials/host_chrome.html @@ -34,7 +34,11 @@ {{else if eq $host.Status "degraded"}} {{else if eq $host.Status "offline"}} - + {{if $host.AlwaysOn}} + + {{else}} + + {{end}} {{else}} {{end}} @@ -45,6 +49,11 @@ style="padding: 2px 8px; border: 1px dashed var(--line); border-radius: 3px; cursor: pointer;" onclick="document.getElementById('tags-edit-{{$host.ID}}').classList.toggle('hidden')" title="Edit tags">{{if $host.Tags}}edit tags{{else}}add tags{{end}} + {{if $host.AlwaysOn}}24×7{{end}} +
{{if gt $page.ScheduleVersion 0}} @@ -80,6 +89,24 @@
Comma-separated. Lowercased automatically.
+ {{/* Presence-mode editor — hidden by default; toggled by the + "presence" button. Checkbox present => always-on (24×7); + unchecked => intermittent (laptop): no offline alerts, shows + "asleep", auto-catches-up a missed backup on reconnect. */}} +
{{$host.OS}}/{{$host.Arch}} · @@ -88,7 +115,11 @@ restic {{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}} · {{if eq $host.Status "offline"}} - last seen {{relTime $host.LastSeenAt}} + {{if $host.AlwaysOn}} + last seen {{relTime $host.LastSeenAt}} + {{else}} + asleep · last seen {{relTime $host.LastSeenAt}} · will catch up on return + {{end}} {{else}} online · last heartbeat {{relTime $host.LastSeenAt}} {{end}} diff --git a/web/templates/partials/host_row.html b/web/templates/partials/host_row.html index d005676..92ba417 100644 --- a/web/templates/partials/host_row.html +++ b/web/templates/partials/host_row.html @@ -8,7 +8,11 @@ {{- else if eq $h.Status "degraded" -}} {{- else if eq $h.Status "offline" -}} - + {{- if $h.AlwaysOn -}} + + {{- else -}} + + {{- end -}} {{- else -}} {{- end -}} @@ -26,7 +30,11 @@ {{- else if eq (deref $h.LastBackupStatus) "cancelled" -}} cancelled · {{relTime $h.LastBackupAt}} {{- else if eq $h.Status "offline" -}} - last seen {{relTime $h.LastSeenAt}} + {{- if $h.AlwaysOn -}} + last seen {{relTime $h.LastSeenAt}} + {{- else -}} + asleep · {{relTime $h.LastSeenAt}} · will catch up on return + {{- end -}} {{- else -}} never run {{- end -}} @@ -53,7 +61,7 @@
{{- if eq $h.Status "offline" -}} - offline + {{if $h.AlwaysOn}}offline{{else}}asleep{{end}} {{- else if $h.CurrentJobID -}} View job → {{- else if .RunAllScheduleID -}} From 6694dfdc3ada34afab643682223b35e678bbc0a5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:27:33 +0100 Subject: [PATCH 13/16] fix(ui): rebuild CSS bundle so dot-asleep ships to the browser --- web/static/css/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/static/css/styles.css b/web/static/css/styles.css index a838a57..5bcb559 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.btn-amber{background:var(--warn);border-color:var(--warn);color:oklch(.18 .01 80)}.btn-amber:hover{filter:brightness(1.08)}.btn-amber:disabled,.btn-amber[disabled]{cursor:not-allowed;opacity:.45;pointer-events:none}.update-chip{align-items:center;background:color-mix(in oklch,var(--warn),transparent 30%);border:1px solid color-mix(in oklch,var(--warn),transparent 50%);border-radius:3px;color:oklch(.18 .01 80);display:inline-flex;font-size:10px;font-weight:500;gap:4px;line-height:1.4;padding:1px 6px;white-space:nowrap}.hero-tile{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;display:flex;flex-direction:column;gap:4px;padding:14px 16px;text-decoration:none;transition:filter .12s ease,background .12s ease}.hero-tile:hover{filter:brightness(1.08)}.hero-tile .hero-num{color:var(--ink);font-family:JetBrains Mono,ui-monospace,monospace;font-size:22px;font-weight:500;letter-spacing:-.01em}.hero-tile .hero-label{color:var(--ink-mute);font-size:11.5px}.hero-tile--amber{background:color-mix(in oklch,var(--warn),transparent 88%);border-color:color-mix(in oklch,var(--warn),transparent 60%)}.hero-tile--amber .hero-num{color:oklch(.86 .13 80)}.hero-tile--amber .hero-label{color:oklch(.78 .08 80)}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr 96px .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag.tag-active{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%);color:var(--accent)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header,.user-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover,.user-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph,.user-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.jobs-row{align-items:center;display:grid;font-size:12.5px;gap:14px;grid-template-columns:110px 110px 90px 1fr 1fr 28px;padding:9px 14px}.jobs-row.head{color:var(--ink-mid);font-size:11px;letter-spacing:.08em;padding-bottom:11px;padding-top:11px;text-transform:uppercase}.jobs-row.clickable{position:relative}.jobs-row.clickable .row-link{display:block;inset:0;position:absolute;z-index:0}.jobs-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.jobs-row.clickable>*{pointer-events:none;position:relative;z-index:1}.jobs-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.h-\[2px\]{height:2px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-\[420px\]{width:420px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink:hover{color:oklch(.96 .005 250)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-asleep{background:var(--ink-fade);opacity:.6}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.btn-amber{background:var(--warn);border-color:var(--warn);color:oklch(.18 .01 80)}.btn-amber:hover{filter:brightness(1.08)}.btn-amber:disabled,.btn-amber[disabled]{cursor:not-allowed;opacity:.45;pointer-events:none}.update-chip{align-items:center;background:color-mix(in oklch,var(--warn),transparent 30%);border:1px solid color-mix(in oklch,var(--warn),transparent 50%);border-radius:3px;color:oklch(.18 .01 80);display:inline-flex;font-size:10px;font-weight:500;gap:4px;line-height:1.4;padding:1px 6px;white-space:nowrap}.hero-tile{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;display:flex;flex-direction:column;gap:4px;padding:14px 16px;text-decoration:none;transition:filter .12s ease,background .12s ease}.hero-tile:hover{filter:brightness(1.08)}.hero-tile .hero-num{color:var(--ink);font-family:JetBrains Mono,ui-monospace,monospace;font-size:22px;font-weight:500;letter-spacing:-.01em}.hero-tile .hero-label{color:var(--ink-mute);font-size:11.5px}.hero-tile--amber{background:color-mix(in oklch,var(--warn),transparent 88%);border-color:color-mix(in oklch,var(--warn),transparent 60%)}.hero-tile--amber .hero-num{color:oklch(.86 .13 80)}.hero-tile--amber .hero-label{color:oklch(.78 .08 80)}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr 96px .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag.tag-active{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%);color:var(--accent)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header,.user-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover,.user-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph,.user-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.jobs-row{align-items:center;display:grid;font-size:12.5px;gap:14px;grid-template-columns:110px 110px 90px 1fr 1fr 28px;padding:9px 14px}.jobs-row.head{color:var(--ink-mid);font-size:11px;letter-spacing:.08em;padding-bottom:11px;padding-top:11px;text-transform:uppercase}.jobs-row.clickable{position:relative}.jobs-row.clickable .row-link{display:block;inset:0;position:absolute;z-index:0}.jobs-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.jobs-row.clickable>*{pointer-events:none;position:relative;z-index:1}.jobs-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.h-\[2px\]{height:2px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-\[420px\]{width:420px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink:hover{color:oklch(.96 .005 250)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} From 10b2518323ceb8d9c66776145f99a781362dbcef Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 15 Jun 2026 21:30:23 +0100 Subject: [PATCH 14/16] docs(tasks): record NS-08 always-on/intermittent host mode --- tasks.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks.md b/tasks.md index 612601f..d763b32 100644 --- a/tasks.md +++ b/tasks.md @@ -499,6 +499,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [x] **NS-05** Drop redundant `actions/setup-go` from `.gitea/workflows/ci.yml`. ✅ Already gone — verified `.gitea/workflows/ci.yml` has zero `actions/setup-go@v5` invocations and no `GO_VERSION` env; the file's header comment now documents that the runner image (`gitea.dcglab.co.uk/steve/ci-runner-go`) is the single source of truth for the Go version. Closing as done; no further code change needed. - [x] **NS-06** Remove the permanently-disabled "Run backup now" button from `web/templates/partials/host_chrome.html`. ✅ Landed: dropped the disabled tombstone button from the host header action row; only "Edit credentials" + the ⋯ menu remain. Per-source-group Run-now on `/hosts/{id}/sources` is the only path now. No e2e change needed — `smoke.spec.ts` does not assert on host_chrome's button row. - [x] **NS-07** Relative timestamps go stale on long-open tabs. ✅ Landed: `formatRelTime` now wraps its label in `