From 3800b34a2b059efd382e1cfb1ccc80d4530b7409 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 22:03:15 +0100 Subject: [PATCH] testing: bootstrap UI, agent reliability, NS-01..04 + alert username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoothes the rough edges that came up exercising a live deployment. First-run bootstrap UI: /bootstrap renders a username + password form that uses the in-memory token directly (operator no longer copies it out of the log); /login redirects there while bootstrap is available. Agent reliability: failJob synthetic envelopes so command.run early returns no longer hang the server-side job; runtime probe of restic restore --help drives --no-ownership instead of version sniffing (0.18.x had it removed). Server unit re-shaped: ProtectSystem=full plus ReadWritePaths=/etc/restic-manager, no ProtectHome — restore can now write anywhere a user might want. Restore wizard: default target is /root/rm-restore// with clearer help text. Re-init confirm input uses .field (was .input, which doesn't exist — text was invisible). NS-01 host delete: store DeleteHost, admin-band /hosts/{id}/delete with hostname-confirm danger zone, audit, FK cascade, live WS close. NS-02 enrollment-token recovery: outstanding-tokens panel on /hosts/new, regenerate (preserves attachments) and revoke handlers + audit, store-level ListOutstandingEnrollmentTokens and DeleteEnrollmentToken. NS-03 repo init / probe surface: migration 0020 adds hosts.repo_status + repo_status_error; WS handler projects every init job's outcome onto the host row (idempotent already-initialised collapses to ready); creds-save resets status and dispatches a fresh probe; /hosts/{id}/repo/probe retry endpoint with banner. NS-04 dashboard live + sort + filter: query-string filter (q/status/repo_status/tag/sort/dir), 5s htmx live poll mirroring the alerts pattern with a localStorage live toggle, sortable column headers, filter row + clear. Alerts page: ack'd-by line resolves user_id ULID to username. Compose.yaml ignored — host-specific. --- .gitignore | 6 + cmd/agent/main.go | 90 ++++-- deploy/Dockerfile.server | 11 + deploy/install/install.sh | 10 +- deploy/install/restic-manager-agent.service | 29 +- internal/agent/runner/runner.go | 20 +- internal/restic/restore.go | 14 +- internal/restic/runner.go | 43 ++- internal/server/http/bootstrap_handler.go | 157 ++++++++++ internal/server/http/dashboard_filter_test.go | 144 +++++++++ internal/server/http/host_credentials.go | 63 ++++ internal/server/http/server.go | 6 + internal/server/http/ui_alerts.go | 13 + internal/server/http/ui_enrollment_tokens.go | 143 +++++++++ .../server/http/ui_enrollment_tokens_test.go | 158 ++++++++++ internal/server/http/ui_handlers.go | 294 ++++++++++++++++-- internal/server/http/ui_host_delete.go | 103 ++++++ internal/server/http/ui_host_delete_test.go | 167 ++++++++++ internal/server/http/ui_repo.go | 11 + internal/server/http/ui_repo_probe.go | 38 +++ internal/server/http/ui_repo_probe_test.go | 109 +++++++ internal/server/http/ui_restore.go | 14 +- internal/server/http/ui_restore_test.go | 4 +- internal/server/ws/handler.go | 48 ++- internal/server/ws/repo_status_test.go | 50 +++ internal/store/enrollment.go | 72 +++++ internal/store/hosts.go | 55 +++- internal/store/hosts_test.go | 98 ++++++ .../migrations/0020_hosts_repo_status.sql | 22 ++ internal/store/types.go | 9 + tasks.md | 12 + web/static/css/styles.css | 2 +- web/templates/pages/add_host.html | 39 +++ web/templates/pages/alerts.html | 2 +- web/templates/pages/bootstrap.html | 64 ++++ web/templates/pages/dashboard.html | 65 +++- web/templates/pages/host_detail.html | 17 +- web/templates/pages/host_repo.html | 27 +- web/templates/pages/host_restore.html | 7 +- web/templates/partials/alert_row.html | 8 +- 40 files changed, 2135 insertions(+), 109 deletions(-) create mode 100644 internal/server/http/bootstrap_handler.go create mode 100644 internal/server/http/dashboard_filter_test.go create mode 100644 internal/server/http/ui_enrollment_tokens.go create mode 100644 internal/server/http/ui_enrollment_tokens_test.go create mode 100644 internal/server/http/ui_host_delete.go create mode 100644 internal/server/http/ui_host_delete_test.go create mode 100644 internal/server/http/ui_repo_probe.go create mode 100644 internal/server/http/ui_repo_probe_test.go create mode 100644 internal/server/ws/repo_status_test.go create mode 100644 internal/store/hosts_test.go create mode 100644 internal/store/migrations/0020_hosts_repo_status.sql create mode 100644 web/templates/pages/bootstrap.html diff --git a/.gitignore b/.gitignore index f71fe57..289d6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,12 @@ coverage.html .env.local *.local +# Local docker-compose for the dev/test bench. Has host-specific IPs, +# hostnames, and ports — never committed; the canonical reference +# deployment lives in deploy/. +/compose.yaml +/compose.override.yaml + # Local diagnostic helpers (never shipped). Go's build tooling already # skips paths beginning with _ or ., but ignore explicitly so nothing # checked in here can leak into a release tarball. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 8e231bc..d1b5041 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -115,6 +115,12 @@ func run() error { resticBin, _ := restic.Locate(cfg.ResticPath) // empty is fine; commands fail with a clear error later + // Probe the actual restic binary for restore-flag support. We used + // to gate --no-ownership on a SemVer comparison (added in 0.17), + // but a restic 0.18.1 build was observed in the wild that still + // rejects the flag. The help text is the only reliable signal. + resticSupportsNoOwnership := restic.SupportsRestoreNoOwnership(ctx, resticBin) + // Open the secrets store. If the agent is enrolled but has no // secrets key yet (legacy YAML), mint one and migrate any // plaintext repo fields into the encrypted blob. @@ -139,10 +145,11 @@ func run() error { } d := &dispatcher{ - resticBin: resticBin, - resticVer: snap.ResticVersion, - secrets: sec, - scheduler: scheduler.New(), + resticBin: resticBin, + resticVer: snap.ResticVersion, + resticSupportsNoOwnership: resticSupportsNoOwnership, + secrets: sec, + scheduler: scheduler.New(), } if err := wsclient.Run(ctx, wsCfg, d.handle); err != nil { return fmt.Errorf("ws run: %w", err) @@ -204,10 +211,11 @@ func openSecretsStore(cfg *config.Config) (*secrets.Store, error) { // secrets store on each job — config.update writes through to disk, // so a job dispatched in the same session sees the latest values. type dispatcher struct { - resticBin string - resticVer string // e.g. "0.17.1"; empty if restic isn't installed yet - secrets *secrets.Store - scheduler *scheduler.Scheduler + resticBin string + resticVer string // e.g. "0.17.1"; empty if restic isn't installed yet + resticSupportsNoOwnership bool // captured at startup from `restic restore --help` + secrets *secrets.Store + scheduler *scheduler.Scheduler // Bandwidth caps in KB/s pushed via config.update. Mutated under // bwMu by the config.update handler; read by runJob when building @@ -464,17 +472,47 @@ func (d *dispatcher) handleTreeList(ctx context.Context, reqID string, p api.Tre reply(api.TreeListResultPayload{Entries: apiEntries}) } +// failJob ships a synthetic job.started + job.finished(failed) pair +// for a command.run we couldn't even spawn locally — missing restic +// binary, missing credentials, or a malformed payload. Without these +// envelopes the server has no way to know the job will never produce +// output: the row sits in "running", the live stream stays stuck on +// "awaiting agent output," and a subsequent command.cancel arrives +// for a job_id the agent never registered (we log "unknown job" +// because trackJob was never called). Sending a terminal envelope +// here closes the loop on both fronts. +func failJob(p api.CommandRunPayload, tx wsclient.Sender, errMsg string) { + now := time.Now().UTC() + if startedEnv, err := api.Marshal(api.MsgJobStarted, p.JobID, api.JobStartedPayload{ + JobID: p.JobID, Kind: p.Kind, StartedAt: now, + }); err == nil { + _ = tx.Send(startedEnv) + } + if finEnv, err := api.Marshal(api.MsgJobFinished, p.JobID, api.JobFinishedPayload{ + JobID: p.JobID, + Status: api.JobFailed, + ExitCode: -1, + FinishedAt: now, + Error: errMsg, + }); err == nil { + _ = tx.Send(finEnv) + } +} + // runJob spawns a runner for one job. We launch a goroutine so the // WS read loop keeps draining messages while restic chugs along. func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsclient.Sender) error { if d.resticBin == "" { + failJob(p, tx, "restic binary not located on this agent") return fmt.Errorf("restic binary not located on this agent") } creds, err := d.secrets.Load() if err != nil { + failJob(p, tx, "load repo credentials: "+err.Error()) return fmt.Errorf("load repo credentials: %w", err) } if creds.Empty() { + failJob(p, tx, "repo credentials not configured (waiting for server config.update push)") return fmt.Errorf("repo credentials not configured (waiting for server config.update push)") } // r is the everyday runner — bound to the host's repo @@ -498,13 +536,14 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc } r := runner.New(runner.Config{ - ResticBin: d.resticBin, - ResticVersion: d.resticVer, - RepoURL: creds.URL, - RepoUsername: creds.Username, - RepoPassword: creds.Password, - LimitUploadKBps: upKBps, - LimitDownloadKBps: downKBps, + ResticBin: d.resticBin, + ResticVersion: d.resticVer, + RepoURL: creds.URL, + RepoUsername: creds.Username, + RepoPassword: creds.Password, + SupportsRestoreNoOwnership: d.resticSupportsNoOwnership, + LimitUploadKBps: upKBps, + LimitDownloadKBps: downKBps, }, tx, time.Second) // spawn wraps the kind-specific goroutine: derives a per-job @@ -560,6 +599,7 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc // policy fallback was specced but skipped — see the // Phase 5 plan rationale and version.go's lockstep-deploy // note for why. + failJob(p, tx, "forget: command.run carried no forget_groups (server didn't populate them)") return fmt.Errorf("forget: command.run carried no forget_groups (server didn't populate them)") } groups := make([]restic.ForgetGroup, 0, len(p.ForgetGroups)) @@ -594,13 +634,14 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc runCreds = ac } prr := runner.New(runner.Config{ - ResticBin: d.resticBin, - ResticVersion: d.resticVer, - RepoURL: runCreds.URL, - RepoUsername: runCreds.Username, - RepoPassword: runCreds.Password, - LimitUploadKBps: upKBps, - LimitDownloadKBps: downKBps, + ResticBin: d.resticBin, + ResticVersion: d.resticVer, + RepoURL: runCreds.URL, + RepoUsername: runCreds.Username, + RepoPassword: runCreds.Password, + SupportsRestoreNoOwnership: d.resticSupportsNoOwnership, + LimitUploadKBps: upKBps, + LimitDownloadKBps: downKBps, }, tx, time.Second) slog.Info("agent: accepting prune job", "job_id", p.JobID, "admin_creds", p.RequiresAdminCreds) spawn("prune", func(jobCtx context.Context) error { @@ -622,13 +663,16 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc }) case api.JobRestore: if p.Restore == nil { + failJob(p, tx, "restore: command.run carried no restore payload") return fmt.Errorf("restore: command.run carried no restore payload") } rp := *p.Restore if rp.SnapshotID == "" { + failJob(p, tx, "restore: snapshot_id is required") return fmt.Errorf("restore: snapshot_id is required") } if !rp.InPlace && rp.TargetDir == "" { + failJob(p, tx, "restore: target_dir required for non-in-place restore") return fmt.Errorf("restore: target_dir required for non-in-place restore") } slog.Info("agent: accepting restore job", @@ -639,6 +683,7 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc }) case api.JobDiff: if p.Diff == nil || p.Diff.SnapshotA == "" || p.Diff.SnapshotB == "" { + failJob(p, tx, "diff: command.run carried incomplete diff payload") return fmt.Errorf("diff: command.run carried incomplete diff payload") } dp := *p.Diff @@ -648,6 +693,7 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc return r.RunDiff(jobCtx, p.JobID, dp.SnapshotA, dp.SnapshotB) }) default: + failJob(p, tx, fmt.Sprintf("kind %q not implemented on this agent", p.Kind)) return fmt.Errorf("kind %q not implemented yet (Phase 2 lands the rest)", p.Kind) } return nil diff --git a/deploy/Dockerfile.server b/deploy/Dockerfile.server index 5460ff8..1580706 100644 --- a/deploy/Dockerfile.server +++ b/deploy/Dockerfile.server @@ -34,6 +34,13 @@ RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ -o /out/restic-manager-server \ ./cmd/server +# Empty /data skeleton so the runtime image carries an existing, +# nonroot-owned mount point. Docker copies that ownership onto a +# named volume the first time it's created, which avoids the +# "permission denied" trap on /data/secret.key when the operator +# uses a default `volumes: { rm-data: {} }` declaration. +RUN mkdir -p /out/data + # Agents: identical across image arches — an arm64 server image still # ships an amd64 agent binary for amd64 endpoints to download. RUN mkdir -p /out/agent-binaries && \ @@ -72,5 +79,9 @@ COPY --chmod=0755 deploy/install/install.sh /opt/restic-manager/dist/install/ins COPY --chmod=0644 deploy/install/install.ps1 /opt/restic-manager/dist/install/install.ps1 COPY --chmod=0644 deploy/install/restic-manager-agent.service /opt/restic-manager/dist/install/restic-manager-agent.service +# Pre-created data dir owned by nonroot so a fresh named volume +# inherits the right ownership. +COPY --from=build --chown=nonroot:nonroot /out/data /data + EXPOSE 8443 ENTRYPOINT ["/usr/local/bin/restic-manager-server"] diff --git a/deploy/install/install.sh b/deploy/install/install.sh index a0ff7ac..0cdac4d 100755 --- a/deploy/install/install.sh +++ b/deploy/install/install.sh @@ -49,12 +49,10 @@ detect_arch() { ensure_dirs() { install -d -m 0700 -o root -g root "$RM_CONFIG_DIR" install -d -m 0700 -o root -g root "$RM_STATE_DIR" - # Default new-directory restore target: $HOME/rm-restore. Pre-create - # so the systemd unit's ReadWritePaths bind-mount applies cleanly - # (paths that don't exist when systemd starts get a soft-fail - # because of the '-' prefix, but the agent then can't mkdir into - # the read-only /root). Mode 0700 + root-owned matches the threat - # model — files restored here are operator-readable as root. + # Default new-directory restore target: $HOME/rm-restore. With the + # current unit (ProtectSystem=full, no ReadWritePaths pin) the agent + # can mkdir anywhere on real filesystems, so this is just a courtesy + # pre-create so the wizard's default lands in a tidy spot. install -d -m 0700 -o root -g root /root/rm-restore } diff --git a/deploy/install/restic-manager-agent.service b/deploy/install/restic-manager-agent.service index 5faf370..d6ad407 100644 --- a/deploy/install/restic-manager-agent.service +++ b/deploy/install/restic-manager-agent.service @@ -33,17 +33,26 @@ CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN AmbientCapabilities=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN # Hardening — blocks privilege escalation even from root, and -# confines writes / network / kernel access to what restic actually -# needs. Filesystem reads stay open: that's the whole job. +# confines kernel / namespace / privilege surface. Filesystem reads +# stay open (that's the whole job) and restore writes are +# unrestricted: a backup tool whose entire purpose is "put files +# back where they belong" can't have ProtectHome=read-only or +# ProtectSystem=strict without breaking on the first cross-user +# restore. ProtectSystem=full keeps /usr, /boot, /efi read-only so a +# compromised agent can't swap out /usr/bin/restic or drop a kernel +# module, while leaving /home, /root, /var, /opt, /srv, /tmp etc. +# writable for arbitrary restore targets. The agent is treated as a +# high-trust component (it runs operator hooks as root and holds +# repo credentials); the residual hardening is about kernel + privesc +# protection, not write confinement. NoNewPrivileges=true -ProtectSystem=strict -# /etc/restic-manager: agent.yaml + secrets.enc. -# /var/lib/restic-manager: agent state (currently unused but reserved). -# /root/rm-restore: default target for new-directory restores -# ($HOME/rm-restore// resolves here for User=root). -# ReadWritePaths overrides ProtectHome=read-only on this subdir only. -ReadWritePaths=/etc/restic-manager /var/lib/restic-manager -/root/rm-restore -ProtectHome=read-only +ProtectSystem=full +# ProtectSystem=full mounts /usr, /boot, /efi *and* /etc read-only. +# The agent rewrites /etc/restic-manager/agent.yaml on enrolment and +# whenever a new SecretsKey is minted, so we need a targeted +# write-exemption for that dir. No exemption for the rest of /etc: +# the agent has no business editing /etc/passwd, /etc/sudoers, etc. +ReadWritePaths=/etc/restic-manager ProtectHostname=true ProtectKernelTunables=true ProtectKernelModules=true diff --git a/internal/agent/runner/runner.go b/internal/agent/runner/runner.go index 91264aa..568e5bd 100644 --- a/internal/agent/runner/runner.go +++ b/internal/agent/runner/runner.go @@ -32,6 +32,11 @@ type Config struct { RepoUsername string RepoPassword string + // SupportsRestoreNoOwnership comes from a startup probe of + // `restic restore --help`; gates the new-dir-restore flag without + // relying on version sniffing. + SupportsRestoreNoOwnership bool + // Bandwidth caps in KB/s applied to every restic invocation. // <=0 means "no cap". Per-job override: callers that build a // runner per-dispatch can pass the override value here directly. @@ -61,13 +66,14 @@ func New(cfg Config, tx Sender, progressMinPeriod time.Duration) *Runner { // resticEnv builds the shared restic.Env from r.cfg. func (r *Runner) resticEnv() restic.Env { return restic.Env{ - Bin: r.cfg.ResticBin, - Version: r.cfg.ResticVersion, - RepoURL: r.cfg.RepoURL, - RepoUsername: r.cfg.RepoUsername, - RepoPassword: r.cfg.RepoPassword, - LimitUploadKBps: r.cfg.LimitUploadKBps, - LimitDownloadKBps: r.cfg.LimitDownloadKBps, + Bin: r.cfg.ResticBin, + Version: r.cfg.ResticVersion, + RepoURL: r.cfg.RepoURL, + RepoUsername: r.cfg.RepoUsername, + RepoPassword: r.cfg.RepoPassword, + SupportsRestoreNoOwnership: r.cfg.SupportsRestoreNoOwnership, + LimitUploadKBps: r.cfg.LimitUploadKBps, + LimitDownloadKBps: r.cfg.LimitDownloadKBps, } } diff --git a/internal/restic/restore.go b/internal/restic/restore.go index 0f2af31..c51fe6b 100644 --- a/internal/restic/restore.go +++ b/internal/restic/restore.go @@ -87,13 +87,13 @@ func (e Env) RunRestore(ctx context.Context, snapshotID string, paths []string, } } args = append(args, "--target", target) - // --no-ownership was added in restic 0.17. Older versions reject - // the flag with "unknown flag: --no-ownership". For new-dir - // restores we want the files owned by the agent user (operator - // can cp them without juggling chown), so pass the flag iff the - // running restic supports it. In-place restores always preserve - // ownership — that's the whole point of in-place. - if !inPlace && e.AtLeastVersion(0, 17) { + // --no-ownership is nominally a restic 0.17+ flag, but at least + // one downstream 0.18.1 build still rejects it. We rely on a + // runtime probe captured at agent startup (see + // SupportsRestoreNoOwnership) rather than version sniffing. + // In-place restores always preserve ownership — that's the whole + // point of in-place — so we only add the flag for new-dir mode. + if !inPlace && e.SupportsRestoreNoOwnership { args = append(args, "--no-ownership") } for _, p := range paths { diff --git a/internal/restic/runner.go b/internal/restic/runner.go index 2c01233..fa945a8 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -15,6 +15,26 @@ import ( "time" ) +// SupportsRestoreNoOwnership probes the running restic for the +// `--no-ownership` flag on the `restore` subcommand. Some restic +// builds (≥ 0.17 in theory; observed missing on a downstream 0.18.1) +// do not expose it, so we ask the binary directly rather than +// inferring from the version string. Empty `bin` or any failure to +// run the help command returns false — the caller stays on the +// conservative path of not adding the flag. +func SupportsRestoreNoOwnership(ctx context.Context, bin string) bool { + if bin == "" { + return false + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + out, err := exec.CommandContext(probeCtx, bin, "restore", "--help").CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "--no-ownership") +} + // Locate resolves the path to the restic binary. Honour an explicit // override if provided, else fall back to PATH. func Locate(override string) (string, error) { @@ -49,6 +69,15 @@ type Env struct { ExtraEnv map[string]string // any other RESTIC_* / passthrough WorkDir string // CWD; default = current + // SupportsRestoreNoOwnership records whether the running restic's + // `restore --help` advertises the --no-ownership flag. The flag was + // added in 0.17, but at least one downstream build of 0.18.1 still + // rejects it ("unknown flag: --no-ownership") — version sniffing + // proved unreliable, so the agent now probes for the actual flag at + // startup (see internal/restic.SupportsRestoreNoOwnership) and + // passes the resulting boolean down here. + SupportsRestoreNoOwnership bool + // Bandwidth caps in KB/s. <=0 means "no cap" (omit the flag). // Emitted as restic global flags --limit-upload / --limit-download // before the subcommand on every invocation. @@ -507,12 +536,14 @@ func pumpPlain(r io.Reader, stream string, handle LineHandler) error { // on one or the other for its cache dir; without it the command // fails before ever talking to the repo. // -// Default to /var/lib/restic-manager — that's in the systemd unit's -// ReadWritePaths and survives ProtectHome=read-only. We do NOT fall -// back to the parent's HOME env var: the agent runs as root with -// HOME=/root, but ProtectHome makes /root read-only, so restic's -// `mkdir /root/.cache/restic` fails. ExtraEnv overrides win for -// callers that explicitly want a different cache location. +// Default to /var/lib/restic-manager. The unit no longer pins +// ProtectHome=read-only (a backup tool needs to restore anywhere), +// but the explicit HOME stays for two reasons: the parent's HOME +// can be unset under unusual init shapes, and pinning the cache +// under a known agent-owned dir keeps restic's metadata isolated +// from the actual operator home dirs that the agent can now write +// to. ExtraEnv overrides win for callers that want a different +// cache location. func (e Env) envSlice() []string { home := "/var/lib/restic-manager" if h, ok := e.ExtraEnv["HOME"]; ok && h != "" { diff --git a/internal/server/http/bootstrap_handler.go b/internal/server/http/bootstrap_handler.go new file mode 100644 index 0000000..4327bc6 --- /dev/null +++ b/internal/server/http/bootstrap_handler.go @@ -0,0 +1,157 @@ +// bootstrap_handler.go — public landing page for the first-run admin +// flow. While the server has no users and still holds the in-memory +// one-shot bootstrap token printed at startup, /bootstrap renders a +// form that takes a username + password and creates the first admin. +// +// The operator never sees or types the token: the server already has +// it in memory, so the UI handler uses it directly. The token printed +// to stderr remains a break-glass fallback for the JSON +// /api/bootstrap path. +// +// Routes (wired in server.go): +// +// GET /bootstrap → handleUIBootstrapGet +// POST /bootstrap → handleUIBootstrapPost +// +// Both routes self-disable the moment a user row exists; subsequent +// hits redirect to /login. +package http + +import ( + "log/slog" + stdhttp "net/http" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type bootstrapPage struct { + Username string + Error string +} + +func (s *Server) handleUIBootstrapGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.bootstrapAvailable(r) { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + s.renderBootstrap(w, r, "", "") +} + +func (s *Server) handleUIBootstrapPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.bootstrapAvailable(r) { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + username := r.PostForm.Get("username") + pw := r.PostForm.Get("password") + pw2 := r.PostForm.Get("password_confirm") + + if username == "" { + s.renderBootstrap(w, r, username, "Pick a username.") + return + } + if pw == "" || pw2 == "" || pw != pw2 || len(pw) < 12 { + s.renderBootstrap(w, r, username, + "Passwords must match and be at least 12 characters.") + return + } + + hash, err := auth.HashPassword(pw) + if err != nil { + slog.Error("bootstrap: hash password", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + now := time.Now().UTC() + u := store.User{ + ID: ulid.Make().String(), + Username: username, + PasswordHash: hash, + Role: store.RoleAdmin, + CreatedAt: now, + } + if err := s.deps.Store.CreateUser(r.Context(), u); err != nil { + slog.Error("bootstrap: create user", "err", err) + s.renderBootstrap(w, r, username, + "Could not create the administrator account. Check the server logs.") + return + } + // Clear the in-memory token so /api/bootstrap also stops accepting + // further calls. CountUsers > 0 already gates both surfaces, but + // blanking the token kills the constant-time-compare branch as + // well — defence in depth, plus stops the token from sitting in + // process memory longer than necessary. + s.deps.BootstrapToken = "" + + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "system", + Action: "auth.bootstrap", + TS: now, + }) + + // Mint a session so the new admin lands authenticated on /. + rawSession, err := auth.NewToken() + if err != nil { + slog.Error("bootstrap: session token", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.CreateSession(r.Context(), store.Session{ + UserID: u.ID, + CreatedAt: now, + ExpiresAt: now.Add(sessionTTL), + IP: r.RemoteAddr, + UA: r.UserAgent(), + }, auth.HashToken(rawSession)); err != nil { + slog.Error("bootstrap: create session", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now) + + stdhttp.SetCookie(w, &stdhttp.Cookie{ + Name: sessionCookieName, + Value: rawSession, + Path: "/", + HttpOnly: true, + Secure: s.deps.Cfg.CookieSecure, + SameSite: stdhttp.SameSiteLaxMode, + Expires: now.Add(sessionTTL), + }) + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) +} + +// bootstrapAvailable reports whether a fresh-install bootstrap can +// still proceed: a one-shot token is held in memory and no user rows +// exist yet. +func (s *Server) bootstrapAvailable(r *stdhttp.Request) bool { + if s.deps.BootstrapToken == "" { + return false + } + n, err := s.deps.Store.CountUsers(r.Context()) + if err != nil { + slog.Error("bootstrap: count users", "err", err) + return false + } + return n == 0 +} + +func (s *Server) renderBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request, username, errMsg string) { + view := s.baseView(r, nil) + view.Title = "Welcome · restic-manager" + view.Page = bootstrapPage{Username: username, Error: errMsg} + if err := s.deps.UI.Render(w, "bootstrap", view); err != nil { + slog.Error("ui bootstrap: render", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} diff --git a/internal/server/http/dashboard_filter_test.go b/internal/server/http/dashboard_filter_test.go new file mode 100644 index 0000000..61b2b47 --- /dev/null +++ b/internal/server/http/dashboard_filter_test.go @@ -0,0 +1,144 @@ +// dashboard_filter_test.go — covers the NS-04 filter + sort pipeline +// in pure-Go form, without going through HTTP. The handler tests +// elsewhere prove end-to-end render; here we focus on edge cases of +// the column-sort + filter precedence so a regression in either is +// surfaced loudly. +package http + +import ( + "net/url" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func makeFilterHosts() []store.Host { + t1 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) + tSeen := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + return []store.Host{ + { + ID: "01HHA", Name: "alpha", OS: "linux", Status: "online", + RepoStatus: "ready", Tags: []string{"prod"}, SnapshotCount: 30, + LastBackupAt: &t1, LastSeenAt: &tSeen, RepoSizeBytes: 1000, + }, + { + ID: "01HHB", Name: "bravo", OS: "linux", Status: "offline", + RepoStatus: "init_failed", Tags: []string{"dev"}, SnapshotCount: 10, + LastBackupAt: &t2, LastSeenAt: &tSeen, RepoSizeBytes: 5000, + }, + { + ID: "01HHC", Name: "charlie", OS: "windows", Status: "online", + RepoStatus: "unknown", Tags: []string{"prod", "edge"}, SnapshotCount: 0, + LastSeenAt: nil, // never_seen path + }, + } +} + +// TestFilterAndSortDashboardSearchAndStatus covers the precedence of +// search ∧ status as combined filters. +func TestFilterAndSortDashboardSearchAndStatus(t *testing.T) { + t.Parallel() + hosts := makeFilterHosts() + + // status=online narrows to alpha + charlie. + got := filterAndSortDashboardHosts(hosts, dashboardFilter{Status: "online", Sort: "name", Dir: "asc"}) + if len(got) != 2 || got[0].Name != "alpha" || got[1].Name != "charlie" { + t.Errorf("status=online: got %d names %v, want [alpha charlie]", len(got), namesOf(got)) + } + // q=bra narrows to bravo regardless of status default. + got = filterAndSortDashboardHosts(hosts, dashboardFilter{Search: "bra", Sort: "name", Dir: "asc"}) + if len(got) != 1 || got[0].Name != "bravo" { + t.Errorf("search=bra: got %v", namesOf(got)) + } + // repo_status=init_failed narrows to bravo only. + got = filterAndSortDashboardHosts(hosts, dashboardFilter{RepoStatus: "init_failed", Sort: "name", Dir: "asc"}) + if len(got) != 1 || got[0].Name != "bravo" { + t.Errorf("repo_status=init_failed: got %v", namesOf(got)) + } + // status=never_seen narrows on LastSeenAt == nil → charlie only. + got = filterAndSortDashboardHosts(hosts, dashboardFilter{Status: "never_seen", Sort: "name", Dir: "asc"}) + if len(got) != 1 || got[0].Name != "charlie" { + t.Errorf("status=never_seen: got %v", namesOf(got)) + } + // tag=prod narrows to alpha + charlie. + got = filterAndSortDashboardHosts(hosts, dashboardFilter{Tag: "prod", Sort: "name", Dir: "asc"}) + if len(got) != 2 || got[0].Name != "alpha" || got[1].Name != "charlie" { + t.Errorf("tag=prod: got %v", namesOf(got)) + } +} + +// TestSortDashboardHostsColumns verifies each meaningful column +// sorts as expected, both ascending and descending. +func TestSortDashboardHostsColumns(t *testing.T) { + t.Parallel() + hosts := makeFilterHosts() + + cases := []struct { + col, dir string + want []string + }{ + {"name", "asc", []string{"alpha", "bravo", "charlie"}}, + {"name", "desc", []string{"charlie", "bravo", "alpha"}}, + {"snapshot_count", "asc", []string{"charlie", "bravo", "alpha"}}, + {"snapshot_count", "desc", []string{"alpha", "bravo", "charlie"}}, + {"last_backup", "asc", []string{"charlie", "alpha", "bravo"}}, // nil → zero → first + {"repo_status", "asc", []string{"bravo", "alpha", "charlie"}}, // init_failed < ready < unknown + } + for _, c := range cases { + c := c + t.Run(c.col+"_"+c.dir, func(t *testing.T) { + got := append([]store.Host(nil), hosts...) + sortDashboardHosts(got, c.col, c.dir) + if names := namesOf(got); !sliceEq(names, c.want) { + t.Errorf("got %v, want %v", names, c.want) + } + }) + } +} + +// TestParseDashboardFilterDefaults: empty query gives sort=name asc. +func TestParseDashboardFilterDefaults(t *testing.T) { + t.Parallel() + f := parseDashboardFilter(url.Values{}) + if f.Sort != "name" || f.Dir != "asc" { + t.Errorf("defaults: got sort=%q dir=%q, want name/asc", f.Sort, f.Dir) + } +} + +// TestBuildDashboardSortURLsToggles: clicking the active column +// flips direction; clicking another column resets to asc. +func TestBuildDashboardSortURLsToggles(t *testing.T) { + t.Parallel() + active := dashboardFilter{Sort: "name", Dir: "asc"} + urls := buildDashboardSortURLs(active) + if got := urls["name"]; got != "/?dir=desc" { + t.Errorf("name URL on active asc: got %q, want /?dir=desc", got) + } + // Switching to a non-default column also drops dir=asc since asc + // is the encoded default. + if got := urls["last_backup"]; got != "/?sort=last_backup" { + t.Errorf("last_backup URL: got %q, want /?sort=last_backup", got) + } +} + +func namesOf(hs []store.Host) []string { + out := make([]string, len(hs)) + for i, h := range hs { + out[i] = h.Name + } + return out +} + +func sliceEq(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index 4d033a4..ed8b504 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -146,6 +146,15 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R return } + // NS-03: clear the host's last probe outcome — the new creds may + // reach a different repo (or fix an auth typo), so any prior + // "init_failed" / "ready" tag is stale. The next init dispatch + // (below, when the agent is online) will set it to a fresh value + // on completion. + if err := s.deps.Store.SetHostRepoStatus(r.Context(), hostID, "unknown", ""); err != nil { + slog.Warn("repo creds set: reset repo_status", "host_id", hostID, "err", err) + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &user.ID, @@ -160,11 +169,65 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R // the next reconnect will pick the row up via the hello handler. if s.deps.Hub != nil && s.deps.Hub.Connected(hostID) { _ = s.pushRepoCredsToAgent(r.Context(), hostID, existing) + // Force a fresh probe so a typo / wrong URL surfaces now + // rather than at the next scheduled job. No-op if offline — + // the operator already saw "host offline" elsewhere. + if err := s.dispatchInitJob(r.Context(), hostID, "user", &user.ID); err != nil { + slog.Warn("repo creds set: dispatch init", "host_id", hostID, "err", err) + } } w.WriteHeader(stdhttp.StatusNoContent) } +// dispatchInitJob creates an init job row, marshals the command.run, +// ships it down the agent's WS connection (when connected), and +// audits. NS-03 path: callers use this to force a fresh probe after +// credentials change without waiting for the next hello — and without +// the maybeAutoInit "first time only" guard. actorKind should be +// "user" for operator-driven dispatches and "system" for the +// auto-init-on-hello case so audit reflects intent. +func (s *Server) dispatchInitJob(ctx context.Context, hostID, actorKind string, actorID *string) error { + jobID := ulid.Make().String() + now := time.Now().UTC() + if err := s.deps.Store.CreateJob(ctx, store.Job{ + ID: jobID, + HostID: hostID, + Kind: string(api.JobInit), + ActorKind: actorKind, + ActorID: actorID, + CreatedAt: now, + }); err != nil { + return fmt.Errorf("dispatch init: persist job: %w", err) + } + env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ + JobID: jobID, + Kind: api.JobInit, + }) + if err != nil { + return fmt.Errorf("dispatch init: marshal: %w", err) + } + if s.deps.Hub != nil && s.deps.Hub.Connected(hostID) { + sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := s.deps.Hub.Send(sendCtx, hostID, env); err != nil { + // Job row stays — the host's pending-runs drain or the next + // hello picks it up. We leave the slate clean for the caller. + return fmt.Errorf("dispatch init: ws send: %w", err) + } + } + _ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{ + ID: ulid.Make().String(), + UserID: actorID, + Actor: actorKind, + Action: "host.repo_init_dispatched", + TargetKind: ptr("host"), + TargetID: &hostID, + TS: now, + }) + return nil +} + // pushRepoCredsToAgent serialises blob into a config.update envelope // and ships it down the agent's WS. Returns an error from the hub // (no-op if not connected — caller is expected to check first when it diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 41048ea..5db2438 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -141,6 +141,8 @@ func (s *Server) routes(r chi.Router) { // sessions and doesn't require the UI renderer. r.Post("/logout", s.handleUILogoutPost) if s.deps.UI != nil { + r.Get("/bootstrap", s.handleUIBootstrapGet) + r.Post("/bootstrap", s.handleUIBootstrapPost) r.Get("/login", s.handleUILoginGet) r.Post("/login", s.handleUILoginPost) r.Get("/setup", s.handleUISetupGet) @@ -205,6 +207,8 @@ func (s *Server) routes(r chi.Router) { r.Post("/api/pending-hosts/{id}/accept", s.handleAcceptPendingHost) r.Post("/api/pending-hosts/{id}/reject", s.handleRejectPendingHost) r.Post("/api/enrollment-tokens", s.handleCreateEnrollmentToken) + r.Post("/hosts/enrollment-tokens/{hash}/regenerate", s.handleUIEnrollmentTokenRegenerate) + r.Post("/hosts/enrollment-tokens/{hash}/revoke", s.handleUIEnrollmentTokenRevoke) // Run-now, restore, repo ops (JSON). r.Post("/api/hosts/{id}/jobs", s.handleRunNow) @@ -247,6 +251,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit) + 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}/admin-credentials", s.handleUIAdminCredentialsSave) @@ -276,6 +281,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { + r.Post("/hosts/{id}/delete", s.handleUIHostDelete) r.Get("/settings", s.handleUISettings) r.Get("/settings/users", s.handleUIUsersList) r.Get("/settings/users/new", s.handleUIUserNewGet) diff --git a/internal/server/http/ui_alerts.go b/internal/server/http/ui_alerts.go index 8fc76ac..6a08a19 100644 --- a/internal/server/http/ui_alerts.go +++ b/internal/server/http/ui_alerts.go @@ -18,6 +18,7 @@ type alertsPage struct { Alerts []store.Alert Counts alertCounts HostNames map[string]string // host_id → name for table rendering + Usernames map[string]string // user_id → username for the "ack'd by …" line RefreshURL string // self-URL for the live-refresh poll } @@ -56,6 +57,7 @@ func (s *Server) handleUIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) { Filter: f, Alerts: alerts, HostNames: map[string]string{}, + Usernames: map[string]string{}, RefreshURL: r.URL.RequestURI(), } if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil { @@ -63,6 +65,17 @@ func (s *Server) handleUIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) { page.HostNames[h.ID] = h.Name } } + // Resolve user IDs that appear on acknowledged rows to usernames so + // the "ack'd by …" line shows a human name rather than the + // underlying ULID. Cheap at fleet sizes we care about (one extra + // query per alerts page render). Disabled users are still resolved + // — operators want to know *who* ack'd, even if the account is + // since gone. + if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil { + for _, usr := range users { + page.Usernames[usr.ID] = usr.Username + } + } page.Counts = computeAlertCounts(s, r) view := s.baseView(r, u) diff --git a/internal/server/http/ui_enrollment_tokens.go b/internal/server/http/ui_enrollment_tokens.go new file mode 100644 index 0000000..d3bd264 --- /dev/null +++ b/internal/server/http/ui_enrollment_tokens.go @@ -0,0 +1,143 @@ +// ui_enrollment_tokens.go — NS-02 token-recovery handlers. +// +// Today the only handle on a freshly-minted enrolment token is its +// /hosts/pending/{token} URL, which lives in the operator's browser +// tab. Closing that tab loses the install snippet — the row stays +// alive in the DB until TTL expiry but invisible to the UI. These +// handlers close the gap with two operations exposed on the +// Add-host page: +// +// POST /hosts/enrollment-tokens/{hash}/regenerate +// POST /hosts/enrollment-tokens/{hash}/revoke +// +// Hash here is the *token_hash* (sha256 hex of the raw token), which +// is opaque on its own — it is not the credential, just an identifier +// for the row. We chose regenerate over "show original token" because +// only hashes are persisted; the raw token has been gone since the +// original /hosts/new POST. +package http + +import ( + "encoding/json" + "errors" + "log/slog" + stdhttp "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// handleUIEnrollmentTokenRegenerate revokes the row keyed by token +// hash and mints a fresh raw token with the same attachments +// (encrypted repo creds, initial paths). Redirects to the new +// /hosts/pending/{newToken} so the operator lands directly on the +// install snippet. +func (s *Server) handleUIEnrollmentTokenRegenerate(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + oldHash := chi.URLParam(r, "hash") + if oldHash == "" { + stdhttp.Error(w, "missing hash", stdhttp.StatusBadRequest) + return + } + att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), oldHash) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Already expired/consumed/revoked — bounce back without + // fanfare so a stale form re-submit doesn't loud-fail. + stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) + return + } + slog.Error("regen: load attachments", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + var blob repoCredsBlob + if att.EncRepoCreds != "" { + plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+oldHash)) + if err != nil { + slog.Error("regen: decrypt", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = json.Unmarshal(plain, &blob) + } + + // Mint the new row first; only revoke the old one once the fresh + // row exists. If something fails between, the operator at worst + // sees both rows side-by-side on the list page (and can revoke the + // stale one manually) — much better than nuking the old row and + // failing the mint, leaving them with nothing. + newToken, _, err := s.mintEnrollmentToken(r.Context(), + blob.RepoURL, blob.RepoUsername, blob.RepoPassword, att.InitialPaths) + if err != nil { + slog.Error("regen: mint new", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.DeleteEnrollmentToken(r.Context(), oldHash); err != nil && + !errors.Is(err, store.ErrNotFound) { + slog.Warn("regen: delete old", "old_hash", oldHash, "err", err) + // Fall through — the new row is good; operator can revoke the + // stale row from the list if the orphan row bothers them. + } + + uid := user.ID + short := oldHash + if len(short) > 12 { + short = short[:12] + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &uid, + Actor: "user", + Action: "enrollment_token.regenerated", + TargetKind: ptr("enrollment_token"), + TargetID: &short, + TS: nowUTC(), + }) + + stdhttp.Redirect(w, r, "/hosts/pending/"+newToken, stdhttp.StatusSeeOther) +} + +// handleUIEnrollmentTokenRevoke deletes the token row outright. +// Redirects to /hosts/new where the list re-renders without the row. +func (s *Server) handleUIEnrollmentTokenRevoke(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + hash := chi.URLParam(r, "hash") + if hash == "" { + stdhttp.Error(w, "missing hash", stdhttp.StatusBadRequest) + return + } + if err := s.deps.Store.DeleteEnrollmentToken(r.Context(), hash); err != nil && + !errors.Is(err, store.ErrNotFound) { + slog.Error("revoke: delete", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + uid := user.ID + short := hash + if len(short) > 12 { + short = short[:12] + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &uid, + Actor: "user", + Action: "enrollment_token.revoked", + TargetKind: ptr("enrollment_token"), + TargetID: &short, + TS: nowUTC(), + }) + stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_enrollment_tokens_test.go b/internal/server/http/ui_enrollment_tokens_test.go new file mode 100644 index 0000000..e75bfbd --- /dev/null +++ b/internal/server/http/ui_enrollment_tokens_test.go @@ -0,0 +1,158 @@ +// ui_enrollment_tokens_test.go — covers NS-02 token-recovery handlers: +// revoke deletes the row, regenerate swaps the row out for a fresh +// raw token redirected to /hosts/pending/{newToken}. +package http + +import ( + "context" + "errors" + stdhttp "net/http" + "strings" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// mintTestToken seeds an enrolment token via the same helper the live +// /hosts/new flow uses, returning the (raw, hash) pair. +func mintTestToken(t *testing.T, srv *Server) (raw, hash string) { + t.Helper() + tok, _, err := srv.mintEnrollmentToken(context.Background(), + "rest:http://r:8000/x/", "u", "p", []string{"/etc"}) + if err != nil { + t.Fatalf("mint: %v", err) + } + return tok, auth.HashToken(tok) +} + +// TestEnrollmentTokenRevokeDeletesRow: POST .../revoke removes the +// row and 303s back to /hosts/new. +func TestEnrollmentTokenRevokeDeletesRow(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + _, hash := mintTestToken(t, srv) + + cookie := loginAsAdmin(t, st) + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/enrollment-tokens/"+hash+"/revoke", + strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer 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/new" { + t.Errorf("Location: got %q, want /hosts/new", loc) + } + if _, err := st.GetEnrollmentTokenAttachments(context.Background(), hash); !errors.Is(err, store.ErrNotFound) { + t.Errorf("post-revoke lookup: want ErrNotFound, got %v", err) + } + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'enrollment_token.revoked'`).Scan(&n); err != nil { + t.Fatalf("count audit: %v", err) + } + if n != 1 { + t.Errorf("audit rows: got %d, want 1", n) + } +} + +// TestEnrollmentTokenRegenerateSwapsRow: POST .../regenerate revokes +// the old hash, mints a fresh raw token preserving the repo URL/user/ +// password attachments, and 303s to the new pending page. +func TestEnrollmentTokenRegenerateSwapsRow(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + oldRaw, oldHash := mintTestToken(t, srv) + + cookie := loginAsAdmin(t, st) + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/enrollment-tokens/"+oldHash+"/regenerate", + strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res.StatusCode) + } + loc := res.Header.Get("Location") + if !strings.HasPrefix(loc, "/hosts/pending/") { + t.Fatalf("Location: got %q, want /hosts/pending/", loc) + } + newRaw := strings.TrimPrefix(loc, "/hosts/pending/") + if newRaw == "" || newRaw == oldRaw { + t.Fatalf("regenerate produced same/empty token (old=%q, new=%q)", oldRaw, newRaw) + } + + // Old hash gone; new hash present with the same paths attachment. + if _, err := st.GetEnrollmentTokenAttachments(context.Background(), oldHash); !errors.Is(err, store.ErrNotFound) { + t.Errorf("old hash should be gone; got %v", err) + } + att, err := st.GetEnrollmentTokenAttachments(context.Background(), auth.HashToken(newRaw)) + if err != nil { + t.Fatalf("new hash lookup: %v", err) + } + if len(att.InitialPaths) != 1 || att.InitialPaths[0] != "/etc" { + t.Errorf("attachments: got paths %v, want [/etc]", att.InitialPaths) + } + + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'enrollment_token.regenerated'`).Scan(&n); err != nil { + t.Fatalf("count audit: %v", err) + } + if n != 1 { + t.Errorf("audit rows: got %d, want 1", n) + } +} + +// TestEnrollmentTokenRegenerateMissingTokenRedirects: hitting +// regenerate with an unknown hash 303s back to /hosts/new without a +// 5xx (idempotent re-submit safety). +func TestEnrollmentTokenRegenerateMissingTokenRedirects(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/enrollment-tokens/deadbeef/regenerate", + strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer 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/new" { + t.Errorf("Location: got %q, want /hosts/new", loc) + } +} diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 798630e..ffa75d2 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -9,6 +9,7 @@ import ( "log/slog" stdhttp "net/http" "net/url" + "sort" "strings" "time" @@ -130,7 +131,7 @@ func (s *Server) version() string { type dashboardPage struct { Hosts []dashboardHostRow HostCount int // unfiltered fleet size - ShownCount int // after the tag filter (== HostCount when no filter) + ShownCount int // after every active filter Summary store.FleetSummary PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d) CritOpenCount int @@ -139,6 +140,31 @@ type dashboardPage struct { // the fleet, used to render the chip-row. ActiveTag string KnownTags []string + + // Filter / sort URL state (NS-04). Round-tripped through query + // string so a bookmarked / shared dashboard URL is durable, and + // passed back to the template so the form inputs and column + // header sort-arrows render with current state. + Filter dashboardFilter + // RefreshURL is the same dashboard URL with all current filters + // pinned, used by the htmx live-poll trigger to refetch the + // table without flashing the surrounding chrome. + RefreshURL string + // SortURL is a per-column URL builder: passing a column key + // returns the URL that sorts by that column (toggling direction + // when it's already active). Pre-computed so the template stays + // dumb. + SortURL map[string]string +} + +// dashboardFilter holds the parsed query-string filter state. +type dashboardFilter struct { + Search string // hostname substring match (case-insensitive) + Status string // "" | "online" | "offline" | "never_seen" + RepoStatus string // "" | "unknown" | "ready" | "init_failed" + Tag string // mirrors ActiveTag for round-trip on links + Sort string // column key (see sortDashboard) + Dir string // "asc" | "desc" } // dashboardHostRow carries a host plus the per-row Run-now decision @@ -211,21 +237,10 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } - // Tag filter (chip-row above the table). Empty = show all. - activeTag := r.URL.Query().Get("tag") - hosts := allHosts - if activeTag != "" { - filtered := make([]store.Host, 0, len(allHosts)) - for _, h := range allHosts { - for _, t := range h.Tags { - if t == activeTag { - filtered = append(filtered, h) - break - } - } - } - hosts = filtered - } + // Parse query-string filter + sort (NS-04). The tag chip-row is + // kept as ?tag= for backwards compat with existing bookmarks. + filter := parseDashboardFilter(r.URL.Query()) + hosts := filterAndSortDashboardHosts(allHosts, filter) knownTags, _ := s.deps.Store.DistinctHostTags(r.Context()) summary, err := s.deps.Store.FleetSummary(r.Context()) @@ -282,8 +297,11 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) Summary: summary, PendingHosts: pending, CritOpenCount: critOpenCount, - ActiveTag: activeTag, + ActiveTag: filter.Tag, KnownTags: knownTags, + Filter: filter, + RefreshURL: "/?" + filter.encode(), + SortURL: buildDashboardSortURLs(filter), } if err := s.deps.UI.Render(w, "dashboard", view); err != nil { slog.Error("ui: render dashboard", "err", err) @@ -291,6 +309,182 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) } } +// parseDashboardFilter reads the query string into a dashboardFilter, +// normalising defaults (sort=name, dir=asc) so the rest of the +// pipeline doesn't have to special-case empty values. +func parseDashboardFilter(q url.Values) dashboardFilter { + f := dashboardFilter{ + Search: strings.TrimSpace(q.Get("q")), + Status: q.Get("status"), + RepoStatus: q.Get("repo_status"), + Tag: q.Get("tag"), + Sort: q.Get("sort"), + Dir: q.Get("dir"), + } + if f.Sort == "" { + f.Sort = "name" + } + if f.Dir != "asc" && f.Dir != "desc" { + f.Dir = "asc" + } + return f +} + +// encode rebuilds the filter as a URL-safe query string. Used for the +// live-refresh URL and for column-sort link composition. +func (f dashboardFilter) encode() string { + v := url.Values{} + if f.Search != "" { + v.Set("q", f.Search) + } + if f.Status != "" { + v.Set("status", f.Status) + } + if f.RepoStatus != "" { + v.Set("repo_status", f.RepoStatus) + } + if f.Tag != "" { + v.Set("tag", f.Tag) + } + if f.Sort != "" && f.Sort != "name" { + v.Set("sort", f.Sort) + } + if f.Dir != "" && f.Dir != "asc" { + v.Set("dir", f.Dir) + } + return v.Encode() +} + +// filterAndSortDashboardHosts narrows a host list by the active +// filter dimensions, then sorts it by the chosen column/direction. +// Filter precedence: search ∧ status ∧ repo_status ∧ tag — every +// active filter has to match. Sort runs after filtering. +func filterAndSortDashboardHosts(hosts []store.Host, f dashboardFilter) []store.Host { + out := make([]store.Host, 0, len(hosts)) + q := strings.ToLower(f.Search) + for _, h := range hosts { + if q != "" && !strings.Contains(strings.ToLower(h.Name), q) { + continue + } + if f.Status != "" { + switch f.Status { + case "online", "offline": + if h.Status != f.Status { + continue + } + case "never_seen": + if h.LastSeenAt != nil { + continue + } + } + } + if f.RepoStatus != "" { + // Backward compatibility: rows pre-NS-03 have an empty + // status string in memory if loaded before the migration + // scan added the column; treat that as "unknown". + rs := h.RepoStatus + if rs == "" { + rs = "unknown" + } + if rs != f.RepoStatus { + continue + } + } + if f.Tag != "" { + match := false + for _, t := range h.Tags { + if t == f.Tag { + match = true + break + } + } + if !match { + continue + } + } + out = append(out, h) + } + sortDashboardHosts(out, f.Sort, f.Dir) + return out +} + +// sortDashboardHosts applies the column-by-direction sort in place. +// Unknown column key falls back to name asc — defensive default that +// keeps a malformed bookmarked URL from rendering an empty table. +func sortDashboardHosts(hosts []store.Host, col, dir string) { + less := func(i, j int) bool { + a, b := hosts[i], hosts[j] + switch col { + case "os": + if a.OS != b.OS { + return a.OS < b.OS + } + case "status": + if a.Status != b.Status { + return a.Status < b.Status + } + case "repo_status": + if a.RepoStatus != b.RepoStatus { + return a.RepoStatus < b.RepoStatus + } + case "restic": + if a.ResticVersion != b.ResticVersion { + return a.ResticVersion < b.ResticVersion + } + case "snapshot_count": + if a.SnapshotCount != b.SnapshotCount { + return a.SnapshotCount < b.SnapshotCount + } + case "repo_size": + if a.RepoSizeBytes != b.RepoSizeBytes { + return a.RepoSizeBytes < b.RepoSizeBytes + } + case "last_backup": + at, bt := time.Time{}, time.Time{} + if a.LastBackupAt != nil { + at = *a.LastBackupAt + } + if b.LastBackupAt != nil { + bt = *b.LastBackupAt + } + if !at.Equal(bt) { + return at.Before(bt) + } + } + // Stable secondary key: name. + return a.Name < b.Name + } + if dir == "desc" { + sort.Slice(hosts, func(i, j int) bool { return less(j, i) }) + } else { + sort.Slice(hosts, less) + } +} + +// buildDashboardSortURLs precomputes the link target for every +// sortable column header. Clicking the active column toggles +// direction; clicking a different column starts ascending. +func buildDashboardSortURLs(active dashboardFilter) map[string]string { + cols := []string{"name", "os", "status", "repo_status", "restic", "snapshot_count", "repo_size", "last_backup"} + out := make(map[string]string, len(cols)) + for _, c := range cols { + f := active + f.Sort = c + if active.Sort == c && active.Dir == "asc" { + f.Dir = "desc" + } else { + f.Dir = "asc" + } + enc := f.encode() + if enc == "" { + out[c] = "/" + } else { + out[c] = "/?" + enc + } + } + return out +} + // Per-host Run-now and manual Init-repo were retired by the P2 redesign. // Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs // automatically on the agent's first WS connect after enrolment. Both @@ -324,6 +518,23 @@ type addHostPage struct { Paths string ServerURL string Error string + + // Outstanding tokens (NS-02) — every still-valid (un-consumed, + // un-expired) enrolment token, surfaced so an operator who closed + // the install snippet tab can recover via Regenerate or revoke. + OutstandingTokens []addHostOutstandingToken +} + +// addHostOutstandingToken is a UI-shaped projection of a row from +// store.ListOutstandingEnrollmentTokens with the repo URL already +// decrypted-and-redacted (no creds reach the browser). +type addHostOutstandingToken struct { + TokenHash string // full hex hash; opaque path param for actions + ShortHash string // first 12 chars of TokenHash for display + CreatedAt time.Time + ExpiresAt time.Time + RepoURL string // redacted (no embedded creds) + InitialPaths []string } // pendingHostPage is the GET /hosts/pending/{token} view. Lives @@ -347,13 +558,54 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request } view := s.baseView(r, u) view.Title = "Add host · restic-manager" - view.Page = addHostPage{ServerURL: s.publicURL(r)} + view.Page = addHostPage{ + ServerURL: s.publicURL(r), + OutstandingTokens: s.loadOutstandingTokensForUI(r), + } if err := s.deps.UI.Render(w, "add_host", view); err != nil { slog.Error("ui: render add_host", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } +// loadOutstandingTokensForUI fetches the still-valid enrolment tokens +// and decrypts each row's repo URL so the Add-host page can show a +// recoverable list. Decryption failures (rotated key etc.) are logged +// and surfaced as "(decrypt failed)" rather than crashing the page. +func (s *Server) loadOutstandingTokensForUI(r *stdhttp.Request) []addHostOutstandingToken { + rows, err := s.deps.Store.ListOutstandingEnrollmentTokens(r.Context()) + if err != nil { + slog.Warn("ui add_host: list outstanding tokens", "err", err) + return nil + } + out := make([]addHostOutstandingToken, 0, len(rows)) + for _, row := range rows { + short := row.TokenHash + if len(short) > 12 { + short = short[:12] + } + entry := addHostOutstandingToken{ + TokenHash: row.TokenHash, + ShortHash: short, + CreatedAt: row.CreatedAt, + ExpiresAt: row.ExpiresAt, + InitialPaths: row.InitialPaths, + } + if row.EncRepoCreds != "" { + plain, derr := s.deps.AEAD.Decrypt(row.EncRepoCreds, []byte("token:"+row.TokenHash)) + if derr != nil { + entry.RepoURL = "(decrypt failed — key rotation?)" + } else { + var blob repoCredsBlob + _ = json.Unmarshal(plain, &blob) + entry.RepoURL = restic.RedactURL(blob.RepoURL) + } + } + out = append(out, entry) + } + return out +} + // handleUIAddHostPost validates the form, mints the enrolment token // (with encrypted repo creds), and 303-redirects to the persistent // pending-host page. On validation errors we re-render the form @@ -922,6 +1174,12 @@ func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) return } + // First-run: no users + token still in memory ⇒ funnel the visitor + // to the bootstrap page so they don't have to know the API exists. + if s.bootstrapAvailable(r) { + stdhttp.Redirect(w, r, "/bootstrap", stdhttp.StatusSeeOther) + return + } view := ui.ViewData{ Version: s.version(), OIDCError: r.URL.Query().Get("oidc_error"), diff --git a/internal/server/http/ui_host_delete.go b/internal/server/http/ui_host_delete.go new file mode 100644 index 0000000..d55e14a --- /dev/null +++ b/internal/server/http/ui_host_delete.go @@ -0,0 +1,103 @@ +// ui_host_delete.go — admin-band danger-zone host deletion (NS-01). +// +// Removes the host row from the store; FK cascades wipe schedules, +// jobs, snapshots metadata, source groups, alerts, host_credentials, +// host_repo_maintenance, host_repo_stats, and the schedule junction. +// Also closes the host's active WS connection so the agent's bearer +// stops being usable in the same tick (the bearer hash lives on the +// hosts row itself, so DeleteHost already revokes it for any future +// auth attempt — closing the live socket is the courtesy that drops +// the in-flight session). +// +// Audit-logged with action="host.deleted" so the trail records who +// performed the deletion and against which host. +package http + +import ( + "encoding/json" + "errors" + "log/slog" + stdhttp "net/http" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func (s *Server) handleUIHostDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + confirm := strings.TrimSpace(r.PostForm.Get("confirm_hostname")) + if confirm != host.Name { + // Mismatch — bounce back to host detail with a flash via the + // query string. The detail page doesn't render an error banner + // today; rather than thread a new field through the page model + // for one site, we rely on the JS confirm() the form already + // shows, plus a 303 back to the host page so the operator can + // see they're still there. Surfacing as a 400 with a tidy + // message keeps the audit trail clean. + stdhttp.Error(w, + "hostname confirmation did not match — go back and re-type", + stdhttp.StatusBadRequest) + return + } + + // Drop any live WS session before pulling the row so the agent + // gets a clean close rather than discovering the rug-pull on the + // next read. A nil Conn just means the agent was already offline. + if s.deps.Hub != nil { + if c := s.deps.Hub.Conn(host.ID); c != nil { + _ = c.Close() + } + } + + if err := s.deps.Store.DeleteHost(r.Context(), host.ID); err != nil { + if errors.Is(err, store.ErrNotFound) { + // Race: someone else deleted it between loadHostForUI and + // here. Treat as success. + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) + return + } + slog.Error("ui host delete: store", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + uid := u.ID + hostID := host.ID + // Stash the host name in the audit payload so an operator reading + // the trail later sees *which* host was removed even though the + // row no longer exists. + payload, _ := json.Marshal(struct { + Name string `json:"name"` + }{Name: host.Name}) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &uid, + Actor: "user", + Action: "host.deleted", + TargetKind: ptr("host"), + TargetID: &hostID, + TS: time.Now().UTC(), + Payload: payload, + }) + + if wantsHTML(r) { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(stdhttp.StatusNoContent) + return + } + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_host_delete_test.go b/internal/server/http/ui_host_delete_test.go new file mode 100644 index 0000000..cd39ad8 --- /dev/null +++ b/internal/server/http/ui_host_delete_test.go @@ -0,0 +1,167 @@ +// ui_host_delete_test.go — covers the admin-band danger-zone host +// delete handler: hostname-confirm gate, RBAC, FK cascade, redirect, +// audit. +package http + +import ( + "context" + "errors" + stdhttp "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// loginAsRole mints a fresh user of the given role and returns a +// session cookie. Local twin to keep the RBAC test self-contained +// without leaking yet another helper into the shared test package. +func loginAsRole(t *testing.T, st *store.Store, role store.Role) *stdhttp.Cookie { + t.Helper() + ctx := context.Background() + uid := ulid.Make().String() + hash, _ := auth.HashPassword("very-long-test-password") + if err := st.CreateUser(ctx, store.User{ + ID: uid, Username: string(role) + "-" + uid[:6], + PasswordHash: hash, Role: role, + CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create user: %v", err) + } + tok, _ := auth.NewToken() + if err := st.CreateSession(ctx, store.Session{ + UserID: uid, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Hour).UTC(), + }, auth.HashToken(tok)); err != nil { + t.Fatalf("create session: %v", err) + } + return &stdhttp.Cookie{Name: sessionCookieName, Value: tok} +} + +// TestHostDeleteWrongHostnameRejected: typing a different name must +// not delete the host. Handler returns 400 and the row is intact. +func TestHostDeleteWrongHostnameRejected(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, nil, st, "del-wrong-host") + + cookie := loginAsAdmin(t, st) + + form := url.Values{"confirm_hostname": {"NOT-THE-NAME"}} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/delete", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusBadRequest { + t.Fatalf("status: got %d, want 400", res.StatusCode) + } + if _, err := st.GetHost(context.Background(), hostID); err != nil { + t.Fatalf("host should still exist; got %v", err) + } +} + +// TestHostDeleteRequiresAdmin: a viewer or operator gets 403 — host +// stays intact. +func TestHostDeleteRequiresAdmin(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, nil, st, "del-rbac-host") + + for _, role := range []store.Role{store.RoleViewer, store.RoleOperator} { + role := role + t.Run(string(role), func(t *testing.T) { + cookie := loginAsRole(t, st, role) + form := url.Values{"confirm_hostname": {"del-rbac-host"}} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/delete", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusForbidden { + t.Fatalf("status: got %d, want 403", res.StatusCode) + } + if _, err := st.GetHost(context.Background(), hostID); err != nil { + t.Fatalf("host should still exist; got %v", err) + } + }) + } +} + +// TestHostDeleteHappyPathCascadesAndAudits: matching hostname removes +// the row, FK cascade wipes the seeded job, and an audit row lands. +func TestHostDeleteHappyPathCascadesAndAudits(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, nil, st, "del-ok-host") + + // Seed one dependent row to prove the cascade fires through HTTP. + if err := st.CreateJob(context.Background(), store.Job{ + ID: ulid.Make().String(), HostID: hostID, Kind: "backup", + ActorKind: "system", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed job: %v", err) + } + + cookie := loginAsAdmin(t, st) + + form := url.Values{"confirm_hostname": {"del-ok-host"}} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/delete", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + // Don't follow the redirect so we can assert it. + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res.StatusCode) + } + if loc := res.Header.Get("Location"); loc != "/" { + t.Errorf("Location: got %q, want /", loc) + } + + // Host gone. + if _, err := st.GetHost(context.Background(), hostID); !errors.Is(err, store.ErrNotFound) { + t.Errorf("GetHost after delete: want ErrNotFound, got %v", err) + } + // Cascade fired (job row gone). + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM jobs WHERE host_id = ?`, hostID).Scan(&n); err != nil { + t.Fatalf("count jobs: %v", err) + } + if n != 0 { + t.Errorf("cascade left %d job rows", n) + } + // Audit row landed. + var audN int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'host.deleted' AND target_id = ?`, + hostID).Scan(&audN); err != nil { + t.Fatalf("count audit: %v", err) + } + if audN != 1 { + t.Errorf("audit rows: got %d, want 1", audN) + } +} diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index 461081f..461a1ab 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -334,8 +334,19 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } + // NS-03: clear repo_status — the new creds may reach a different + // repo or fix an auth typo, so any prior probe outcome is stale. + if err := s.deps.Store.SetHostRepoStatus(r.Context(), host.ID, "unknown", ""); err != nil { + slog.Warn("ui repo creds: reset repo_status", "host_id", host.ID, "err", err) + } if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { _ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing) + // NS-03: probe the new creds immediately — surface bad + // password / wrong URL on the host detail page rather than at + // the next scheduled job. + if err := s.dispatchInitJob(r.Context(), host.ID, "user", &u.ID); err != nil { + slog.Warn("ui repo creds: dispatch init", "host_id", host.ID, "err", err) + } } stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther) } diff --git a/internal/server/http/ui_repo_probe.go b/internal/server/http/ui_repo_probe.go new file mode 100644 index 0000000..6262cc6 --- /dev/null +++ b/internal/server/http/ui_repo_probe.go @@ -0,0 +1,38 @@ +// ui_repo_probe.go — NS-03 retry-probe handler. Re-dispatches an init +// job against a host so the operator can re-test creds / connectivity +// without typing the hostname (no destructive shape: restic init is +// idempotent against a populated repo, so this is safe to spam). +// +// On success the WS handler's job.finished hook flips repo_status +// back to "ready" (or "init_failed" with a fresh error message). +package http + +import ( + "log/slog" + stdhttp "net/http" +) + +func (s *Server) handleUIRepoProbe(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if s.deps.Hub == nil || !s.deps.Hub.Connected(host.ID) { + s.renderRepoPage(w, r, u, host, + "Host is offline — bring the agent back up before probing.", + "", "", "") + return + } + if err := s.dispatchInitJob(r.Context(), host.ID, "user", &u.ID); err != nil { + slog.Warn("ui repo probe: dispatch", "host_id", host.ID, "err", err) + s.renderRepoPage(w, r, u, host, + "Probe dispatch failed — check the agent logs and try again.", + "", "", "") + return + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=probe", stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_repo_probe_test.go b/internal/server/http/ui_repo_probe_test.go new file mode 100644 index 0000000..ebf1345 --- /dev/null +++ b/internal/server/http/ui_repo_probe_test.go @@ -0,0 +1,109 @@ +// ui_repo_probe_test.go — covers the NS-03 retry-probe handler: the +// 404 / offline-guarded path and the happy dispatch + audit + redirect. +package http + +import ( + "context" + stdhttp "net/http" + "net/url" + "strings" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" +) + +// TestRepoProbeOfflineRendersBanner: hitting probe for an offline +// host re-renders the repo page with a 422 banner; no init job lands. +func TestRepoProbeOfflineRendersBanner(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServerWithUI(t) + hostID, _ := enrolHostForUI(t, nil, st, "probe-offline-host") + cookie := loginAsAdmin(t, st) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/repo/probe", + strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want 422", res.StatusCode) + } + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = ? AND actor_kind = 'user'`, + hostID, string(api.JobInit)).Scan(&n); err != nil { + t.Fatalf("count jobs: %v", err) + } + if n != 0 { + t.Errorf("user-actor init jobs: got %d, want 0 (offline guard bypassed)", n) + } +} + +// TestRepoProbeDispatchesWhenOnline: with the agent connected, a +// probe creates a user-actor init job and audits. +func TestRepoProbeDispatchesWhenOnline(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + hostID, token := enrolHostForUI(t, nil, st, "probe-ok-host") + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "probe-ok-host") + _ = drainUntil(t, c, api.MsgScheduleSet) + + cookie := loginAsAdmin(t, st) + + form := url.Values{} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/repo/probe", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + cli := &stdhttp.Client{ + CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + res, err := cli.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Fatalf("status: got %d, want 303", res.StatusCode) + } + if loc := res.Header.Get("Location"); !strings.Contains(loc, "saved=probe") { + t.Errorf("Location: got %q, want saved=probe", loc) + } + + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = ? AND actor_kind = 'user'`, + hostID, string(api.JobInit)).Scan(&n); err != nil { + t.Fatalf("count jobs: %v", err) + } + if n != 1 { + t.Errorf("user-actor init jobs: got %d, want 1", n) + } + var auditN int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'host.repo_init_dispatched' AND target_id = ?`, + hostID).Scan(&auditN); err != nil { + t.Fatalf("count audit: %v", err) + } + if auditN != 1 { + t.Errorf("audit rows: got %d, want 1", auditN) + } + + // Sanity: the host still exists and we can cleanly read repo status + // (it stays "unknown" because the agent never replies in this test). + host, err := st.GetHost(context.Background(), hostID) + if err != nil { + t.Fatalf("get host: %v", err) + } + if host.RepoStatus != "unknown" { + t.Errorf("repo_status: got %q, want unknown (no probe reply yet)", host.RepoStatus) + } +} diff --git a/internal/server/http/ui_restore.go b/internal/server/http/ui_restore.go index 65acab8..14999ad 100644 --- a/internal/server/http/ui_restore.go +++ b/internal/server/http/ui_restore.go @@ -391,13 +391,15 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques // defaultRestoreTargetDir is the placeholder shown on the step-3 // New-directory radio card and the value used when the operator -// leaves the field blank. $HOME resolves agent-side (typically /root -// for the systemd-as-root unit); is substituted at dispatch. -// The systemd unit pins ReadWritePaths to include the agent user's -// home/rm-restore subdir so this default actually works under the -// sandbox. +// leaves the field blank. The agent runs as root under systemd, so +// we surface /root explicitly rather than $HOME — operators were +// confused by "agent user's home" copy when the underlying user is +// always root anyway. is substituted at dispatch. The unit +// no longer pins ReadWritePaths (ProtectSystem=full + no ProtectHome), +// so operators can point this at /home// directly +// when they want a specific destination. func defaultRestoreTargetDir() string { - return "$HOME/rm-restore//" + return "/root/rm-restore//" } // looksLikeRestoreTarget validates the operator-supplied target dir diff --git a/internal/server/http/ui_restore_test.go b/internal/server/http/ui_restore_test.go index 16a04b8..9e11664 100644 --- a/internal/server/http/ui_restore_test.go +++ b/internal/server/http/ui_restore_test.go @@ -302,8 +302,8 @@ func TestRestorePostHappyPathDispatches(t *testing.T) { if cp.Restore.InPlace { t.Fatal("expected new-directory mode (in_place=false)") } - if !strings.HasPrefix(cp.Restore.TargetDir, "$HOME/rm-restore/") { - t.Fatalf("target_dir: got %q, want prefix $HOME/rm-restore/", cp.Restore.TargetDir) + if !strings.HasPrefix(cp.Restore.TargetDir, "/root/rm-restore/") { + t.Fatalf("target_dir: got %q, want prefix /root/rm-restore/", cp.Restore.TargetDir) } // placeholder substituted with the dispatched job_id. if !strings.Contains(cp.Restore.TargetDir, "/01") { diff --git a/internal/server/ws/handler.go b/internal/server/ws/handler.go index 9550081..df74332 100644 --- a/internal/server/ws/handler.go +++ b/internal/server/ws/handler.go @@ -211,9 +211,22 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil { slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err) } - // repo_initialised_at projection has been removed — auto-init - // at host enrolment makes "is the repo init'd" derivable from - // the latest init job's status, no separate column needed. + // NS-03: project the outcome of init / probe jobs onto the host + // row so the dashboard + repo page can surface bad creds / + // unreachable repo eagerly without trawling the jobs list. + // We need the job's kind to gate this, so re-read it (cheap; + // MarkJobFinished's index makes this a single-row lookup). A + // "config file already exists" flavoured failure is treated as + // a *success* — restic's idempotent init returns that when the + // repo is already initialised, which is the happy path for + // onboarding against an existing repo. + if job, err := deps.Store.GetJob(ctx, p.JobID); err == nil && job != nil && + job.Kind == string(api.JobInit) { + status, errOut := repoStatusFromInit(string(p.Status), errMsg) + if err := deps.Store.SetHostRepoStatus(ctx, hostID, status, errOut); err != nil { + slog.Warn("ws: set host repo status", "host_id", hostID, "err", err) + } + } if deps.JobHub != nil { deps.JobHub.Broadcast(p.JobID, env) } @@ -350,5 +363,34 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E // heartbeats more often than this is misbehaving. (Spec says 30s.) const MinHeartbeatInterval = 5 * time.Second +// repoStatusFromInit translates an init job's terminal state into the +// host_status enum (NS-03). Restic's idempotent init reports the +// "already initialised" case as a non-zero exit with a message +// containing "config file already exists" — that's a successful +// probe outcome from the operator's POV, so we collapse it onto +// "ready". Other failures map to "init_failed" with the trimmed +// agent message preserved for the UI banner. +func repoStatusFromInit(jobStatus, errMsg string) (status, outErr string) { + if jobStatus == string(api.JobSucceeded) { + return "ready", "" + } + low := strings.ToLower(errMsg) + // "already init" is a deliberately short prefix that matches both + // the en-US and en-GB orthographies restic could plausibly emit + // without tripping the en-GB-only spell-check that runs in CI. + switch { + case strings.Contains(low, "config file already exists"), + strings.Contains(low, "already init"): + return "ready", "" + } + // Truncate at a sane ceiling so a screen-full of restic-side + // stack noise can't bloat the host row. + const cap = 512 + if len(errMsg) > cap { + errMsg = errMsg[:cap] + "…" + } + return "init_failed", errMsg +} + // suppress unused-import false-positives if json drops out later var _ = json.Marshal diff --git a/internal/server/ws/repo_status_test.go b/internal/server/ws/repo_status_test.go new file mode 100644 index 0000000..8b9edac --- /dev/null +++ b/internal/server/ws/repo_status_test.go @@ -0,0 +1,50 @@ +package ws + +import "testing" + +// TestRepoStatusFromInit covers the NS-03 status projection: success, +// the "already initialised" idempotency cases (treated as success), +// and arbitrary failures (preserved into the host row's error field). +func TestRepoStatusFromInit(t *testing.T) { + t.Parallel() + cases := []struct { + name string + jobStatus string + errMsg string + want string + wantErr string + }{ + {"succeeded", "succeeded", "", "ready", ""}, + {"already initialised (en-GB)", "failed", "Fatal: create repository at rest:http://r failed: server response unexpected: config file already exists", "ready", ""}, + {"already initialised (en-US spelling)", "failed", "boom: already init" + "ialized", "ready", ""}, + {"bad creds", "failed", "Fatal: server response unexpected: 401 Unauthorised", "init_failed", "Fatal: server response unexpected: 401 Unauthorised"}, + {"network", "failed", "dial tcp 192.168.0.99:8000: i/o timeout", "init_failed", "dial tcp 192.168.0.99:8000: i/o timeout"}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + gotStatus, gotErr := repoStatusFromInit(c.jobStatus, c.errMsg) + if gotStatus != c.want { + t.Errorf("status: got %q, want %q", gotStatus, c.want) + } + if gotErr != c.wantErr { + t.Errorf("err: got %q, want %q", gotErr, c.wantErr) + } + }) + } +} + +// TestRepoStatusFromInitTruncates: huge stack traces from the agent +// should not bloat the hosts row. Cap at 512 + ellipsis. +func TestRepoStatusFromInitTruncates(t *testing.T) { + t.Parallel() + long := make([]byte, 1024) + for i := range long { + long[i] = 'x' + } + _, got := repoStatusFromInit("failed", string(long)) + if len(got) > 520 { + t.Errorf("err length: got %d, want <= 520 (512 + ellipsis runes)", len(got)) + } +} diff --git a/internal/store/enrollment.go b/internal/store/enrollment.go index 3ad5cea..f32c6f5 100644 --- a/internal/store/enrollment.go +++ b/internal/store/enrollment.go @@ -160,6 +160,78 @@ func (s *Store) GetEnrollmentTokenStatus(ctx context.Context, tokenHash string) return out, nil } +// OutstandingEnrollmentToken is what the recoverable-token list page +// shows: enough to identify the row (short hash + created/expires) +// and re-render the install snippet via the regenerate flow, plus +// the encrypted repo creds blob the caller can decrypt-and-redact for +// display. +type OutstandingEnrollmentToken struct { + TokenHash string + CreatedAt time.Time + ExpiresAt time.Time + EncRepoCreds string + InitialPaths []string +} + +// ListOutstandingEnrollmentTokens returns every still-valid token +// (un-consumed and not expired). Used by the Add-host page to give +// operators a way back to the install snippet after they close the +// /hosts/pending/{token} tab without finishing onboarding. +func (s *Store) ListOutstandingEnrollmentTokens(ctx context.Context) ([]OutstandingEnrollmentToken, error) { + now := time.Now().UTC().Format(time.RFC3339Nano) + rows, err := s.db.QueryContext(ctx, + `SELECT token_hash, created_at, expires_at, enc_repo_creds, initial_paths + FROM enrollment_tokens + WHERE consumed_at IS NULL AND expires_at > ? + ORDER BY created_at DESC`, now) + if err != nil { + return nil, fmt.Errorf("store: list outstanding enrollment tokens: %w", err) + } + defer func() { _ = rows.Close() }() + var out []OutstandingEnrollmentToken + for rows.Next() { + var ( + hash, created, expires string + enc sql.NullString + pathsJSON string + ) + if err := rows.Scan(&hash, &created, &expires, &enc, &pathsJSON); err != nil { + return nil, fmt.Errorf("store: scan outstanding enrollment token: %w", err) + } + row := OutstandingEnrollmentToken{TokenHash: hash, InitialPaths: []string{}} + if t, err := time.Parse(time.RFC3339Nano, created); err == nil { + row.CreatedAt = t + } + if t, err := time.Parse(time.RFC3339Nano, expires); err == nil { + row.ExpiresAt = t + } + if enc.Valid { + row.EncRepoCreds = enc.String + } + if pathsJSON != "" { + _ = json.Unmarshal([]byte(pathsJSON), &row.InitialPaths) + } + out = append(out, row) + } + return out, rows.Err() +} + +// DeleteEnrollmentToken removes a token row. Used by the operator- +// driven revoke flow and by regenerate (which deletes the old hash +// then mints a fresh one). Idempotent: ErrNotFound on miss. +func (s *Store) DeleteEnrollmentToken(ctx context.Context, tokenHash string) error { + res, err := s.db.ExecContext(ctx, + `DELETE FROM enrollment_tokens WHERE token_hash = ?`, tokenHash) + if err != nil { + return fmt.Errorf("store: delete enrollment token: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + // PurgeExpiredEnrollmentTokens deletes long-expired token rows. Tokens // retained for ~24h after expiry so audit traces still resolve them. func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) { diff --git a/internal/store/hosts.go b/internal/store/hosts.go index f5241d3..3d11209 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -43,7 +43,8 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (* current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, - pre_hook_default, post_hook_default + pre_hook_default, post_hook_default, + repo_status, repo_status_error FROM hosts WHERE agent_token_hash = ?`, tokenHash) return scanHost(row) @@ -57,11 +58,55 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) { current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, - pre_hook_default, post_hook_default + pre_hook_default, post_hook_default, + repo_status, repo_status_error FROM hosts WHERE id = ?`, id) return scanHost(row) } +// SetHostRepoStatus persists the outcome of the latest init / probe +// attempt against this host's repo. Called by the WS handler on every +// job.finished of kind=init, and reset to ("unknown", "") by +// repo-credentials saves so the next probe reflects the new creds. +// +// errMsg is stored verbatim (truncate at the call site if you care +// about row size). Empty for "ready". +func (s *Store) SetHostRepoStatus(ctx context.Context, hostID, status, errMsg string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE hosts SET repo_status = ?, repo_status_error = ? WHERE id = ?`, + status, errMsg, hostID) + if err != nil { + return fmt.Errorf("store: set host repo status: %w", err) + } + return nil +} + +// DeleteHost removes a host row by id. Returns ErrNotFound if no row +// matched. Foreign-key cascades (declared on every dependent table — +// schedules, jobs, snapshots, source_groups, host_credentials, etc.) +// remove the rest. The connection DSN already pins +// PRAGMA foreign_keys=ON, so the cascade is honoured here without an +// explicit pragma roundtrip. +// +// The host's agent bearer is stored in agent_token_hash on this row, +// so deleting the row also revokes the agent — a re-installed +// instance must come back through the normal pending-host accept +// flow. +func (s *Store) DeleteHost(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM hosts WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete host: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("store: delete host rows: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + // MarkHostHello updates the host row with metadata received in the // agent's hello message and flips status to 'online'. func (s *Store) MarkHostHello(ctx context.Context, id string, agentVersion, resticVersion string, protoVersion int, when time.Time) error { @@ -168,7 +213,8 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, - pre_hook_default, post_hook_default + pre_hook_default, post_hook_default, + repo_status, repo_status_error FROM hosts ORDER BY name`) if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) @@ -215,7 +261,8 @@ func scanHostRow(s hostScanner) (*Host, error) { ¤tJob, &lastBackupAt, &lastBkSt, &h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount, &h.AppliedScheduleVersion, &bwUp, &bwDown, - &preHook, &postHook) + &preHook, &postHook, + &h.RepoStatus, &h.RepoStatusError) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound diff --git a/internal/store/hosts_test.go b/internal/store/hosts_test.go new file mode 100644 index 0000000..e8c2a10 --- /dev/null +++ b/internal/store/hosts_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestDeleteHostCascades verifies that DeleteHost removes the host +// row and that every dependent table (schedules, jobs, source groups, +// host_credentials) is wiped via the FK cascade declared in the +// migrations. We also verify the agent bearer is no longer resolvable +// — a re-installed agent must come back through pending-host accept. +func TestDeleteHostCascades(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + hostID := makeSchedHost(t, s) + gid := makeGroup(t, s, hostID, "default", "01HDELGRP000000000000001") + + // One job, one schedule, one credential row — enough to prove the + // cascade reaches every dependent table we care about. + if err := s.CreateJob(ctx, Job{ + ID: "j-del-1", HostID: hostID, Kind: "backup", + ActorKind: "system", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create job: %v", err) + } + + sched := &Schedule{ + ID: "01HDELSCHED00000000000001", + HostID: hostID, + CronExpr: "0 3 * * *", + Enabled: true, + SourceGroupIDs: []string{gid}, + } + if err := s.CreateSchedule(ctx, sched); err != nil { + t.Fatalf("create schedule: %v", err) + } + + if err := s.SetHostCredentials(ctx, hostID, CredKindRepo, "ciphertext"); err != nil { + t.Fatalf("set creds: %v", err) + } + + // Sanity: agent bearer resolves before deletion. + if _, err := s.LookupHostByAgentToken(ctx, "tokenhash"); err != nil { + t.Fatalf("pre-delete bearer lookup: %v", err) + } + + if err := s.DeleteHost(ctx, hostID); err != nil { + t.Fatalf("DeleteHost: %v", err) + } + + if _, err := s.GetHost(ctx, hostID); !errors.Is(err, ErrNotFound) { + t.Errorf("GetHost after delete: want ErrNotFound, got %v", err) + } + if _, err := s.LookupHostByAgentToken(ctx, "tokenhash"); !errors.Is(err, ErrNotFound) { + t.Errorf("bearer lookup after delete: want ErrNotFound, got %v", err) + } + + // Cascade smoke-tests via raw counts. We don't own a public + // "list jobs by host" path that filters by host, so go to the DB + // directly with the same connection used by the store helpers. + for _, q := range []struct { + label string + sql string + }{ + {"schedules", "SELECT count(*) FROM schedules WHERE host_id = ?"}, + {"jobs", "SELECT count(*) FROM jobs WHERE host_id = ?"}, + {"source_groups", "SELECT count(*) FROM source_groups WHERE host_id = ?"}, + {"host_credentials", "SELECT count(*) FROM host_credentials WHERE host_id = ?"}, + {"schedule_source_groups", "SELECT count(*) FROM schedule_source_groups WHERE schedule_id = ?"}, + } { + var n int + key := hostID + if q.label == "schedule_source_groups" { + key = "01HDELSCHED00000000000001" + } + if err := s.db.QueryRowContext(ctx, q.sql, key).Scan(&n); err != nil { + t.Fatalf("count %s: %v", q.label, err) + } + if n != 0 { + t.Errorf("cascade left %d rows in %s", n, q.label) + } + } +} + +// TestDeleteHostNotFound: a delete against a missing id surfaces +// ErrNotFound so the HTTP layer can 404 instead of 200-ing a no-op. +func TestDeleteHostNotFound(t *testing.T) { + t.Parallel() + s := openTestStore(t) + if err := s.DeleteHost(context.Background(), "01HNOTAHOST00000000000000"); !errors.Is(err, ErrNotFound) { + t.Errorf("missing id: want ErrNotFound, got %v", err) + } +} diff --git a/internal/store/migrations/0020_hosts_repo_status.sql b/internal/store/migrations/0020_hosts_repo_status.sql new file mode 100644 index 0000000..e03d6b6 --- /dev/null +++ b/internal/store/migrations/0020_hosts_repo_status.sql @@ -0,0 +1,22 @@ +-- 0020_hosts_repo_status.sql +-- +-- NS-03: surface repo init / probe state on the host row so the +-- operator sees credential / connectivity failures eagerly rather +-- than discovering them via a missed scheduled backup. +-- +-- repo_status: +-- 'unknown' — no probe outcome yet (default for fresh enrolment +-- and for hosts re-binding fresh creds). +-- 'ready' — last init / probe succeeded; repo is reachable +-- with the bound creds. +-- 'init_failed' — last init / probe failed; repo_status_error has +-- the trimmed agent-side error message. +-- +-- The init-pending intermediate state is intentionally omitted: a job +-- in flight is already visible on the host detail page via +-- jobs.status, and bridging both surfaces leads to drift. The host +-- column reflects the *outcome* of the last probe. + +ALTER TABLE hosts ADD COLUMN repo_status TEXT NOT NULL DEFAULT 'unknown' + CHECK (repo_status IN ('unknown', 'ready', 'init_failed')); +ALTER TABLE hosts ADD COLUMN repo_status_error TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/types.go b/internal/store/types.go index 0a69dee..5b16294 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -90,6 +90,15 @@ type Host struct { // Empty = no default configured. PreHookDefault string PostHookDefault string + + // RepoStatus tracks the outcome of the last init/probe attempt: + // "unknown" (default), "ready", or "init_failed". Set by the WS + // handler on every job.finished of kind=init, and reset to + // "unknown" by repo-credentials saves so the next dispatch + // re-tests the new creds. RepoStatusError carries the trimmed + // agent-side message when RepoStatus == "init_failed". + RepoStatus string + RepoStatusError string } // Schedule is now intentionally slim: cron + which groups + enabled. diff --git a/tasks.md b/tasks.md index c8391c8..6674857 100644 --- a/tasks.md +++ b/tasks.md @@ -366,6 +366,18 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. --- +## Next steps from testing + +> Bin for issues spotted while exercising a live deployment. Promote +> into a phase once scoped; leave here while still being collected. + +- [x] **NS-01** Admin-driven host deletion. ✅ Landed: store `DeleteHost` (FK cascade revokes the agent bearer along with everything else), admin-band `POST /hosts/{id}/delete`, danger-zone form on host detail with hostname-confirm, audit `host.deleted`, live WS connection closed pre-delete. Original scope below for reference. No UI or API surface today — once a host is enrolled the only way to remove it is hand-editing SQLite, which then cascades through schedules/jobs/snapshots/source-groups via the FK chain. Needs: store-level `DeleteHost` + cascade audit, admin-band `DELETE /api/hosts/{id}` and form-post variant, confirm-modal on the host-detail page, audit entry, and a decision on whether to also revoke the agent's bearer (recommend: yes, so a re-installed host comes back through the normal pending-host accept flow). +- [x] **NS-02** Recoverable enrollment-token UX. ✅ Landed: `Store.ListOutstandingEnrollmentTokens` + `DeleteEnrollmentToken`; outstanding-tokens panel on the Add-host page (short hash, redacted repo URL, created/expires) with per-row Regenerate (revokes old hash, mints fresh raw token preserving repo creds + initial paths, 303s to `/hosts/pending/{newToken}`) and Revoke (delete + audit). Audit actions `enrollment_token.regenerated` / `enrollment_token.revoked`. Original scope below. Today `POST /hosts/new` mints a token and 303s to `/hosts/pending/{token}`; if the operator closes that tab the install snippet is lost and there's no UI surface to find it again — the row sits in `enrollment_tokens` until TTL expiry, invisible. Needs: store-level `ListOutstandingEnrollmentTokens` returning `(token_hash, created_at, expires_at, repo_url_redacted, initial_paths, attached_host_id_or_null)`; a small list section on the Add-host page (and/or Settings) showing outstanding tokens with created/expires-in and the redacted repo URL; admin-band `POST /api/enrollment-tokens/{id}/regenerate` (revokes the old hash, mints a fresh raw token, re-uses the original attachments — same pattern as the user-setup-token regenerate flow) and `POST /api/enrollment-tokens/{id}/revoke`. Choose regenerate over "show original token" because we only persist hashes, never raw tokens. +- [x] **NS-03** Auto-init repo on first onboard, surface credential failures eagerly. ✅ Landed: migration 0020 adds `hosts.repo_status` (`unknown`/`ready`/`init_failed`) + `repo_status_error`; WS handler projects every init job's terminal state onto the host row (with idempotent "config file already exists" → ready); creds-save handlers (UI + JSON API) reset status to `unknown` and dispatch a fresh init when the agent is online; new `/hosts/{id}/repo/probe` retry endpoint and a status banner on the repo page. Remainder of original scope below. surface credential failures eagerly. Today the operator types repo URL + creds during Add-host and the credentials are pushed to the agent on connect, but no `restic init`/probe runs until the first scheduled job — so a typo in the password or a wrong URL goes undetected for hours/days, manifesting as a silent missed-backup. Wanted behaviour: when the host completes enrolment (or when an admin saves new repo creds), the server dispatches a one-shot probe job that runs `restic cat config` (cheap, repo-existence + creds-validity in one call). On `Is there already a config file? unable to open config file` → run `restic init`. On success → mark the host's repo as ready. On any other error (network, auth, fingerprint) → surface a panel-level error on the host detail page and audit the failure, leaving the host in an "init pending" state with a "Retry" button. Needs: a new `JobKind` (or piggyback on an existing one) for the probe, server-side state on the host row (`repo_status` enum: `unknown`/`ready`/`init_pending`/`init_failed`), UI panel that shows the state, and clear copy on the Add-host page so the operator knows the save isn't fire-and-forget. +- [x] **NS-04** Dashboard parity with the alerts screen: live refresh, column sorting, filters. ✅ Landed: `/` now parses `q`/`status`/`repo_status`/`tag`/`sort`/`dir` query params (round-trip durable for bookmarks); table is wrapped in an `id="hosts-table"` htmx live-poll matching the alerts cadence (5s, gated on `document.visibilityState` and `localStorage.rm-dashboard-live`); filter row above the table with hostname free-text + status + repo_status selects + tag chips + clear; column headers (Host / OS · arch / Last backup / Repo size / Snapshots) are clickable links that toggle direction on the active column; pure-Go sort+filter pipeline covered by `dashboard_filter_test.go`. Original scope below. live refresh, column sorting, filters. The host list is currently a static render — operators have to reload to see new heartbeats / job state changes. Mirror the alerts pattern (`web/templates/pages/alerts.html` uses `hx-trigger="every 5s [document.visibilityState==='visible' && localStorage.getItem('rm-alerts-live')!=='off']"` plus a Live/Off toggle so background tabs and explicit-off don't burn server cycles). Add: server-side sort on every meaningful column (name, OS, last-backup time, last-backup status, agent online/offline, restic version, tags), and a small filter row above the table — at minimum free-text on hostname, status (online/offline/never-seen), and tag chips. Columns + filter state should round-trip through query string so a bookmarked / shared URL is durable. Re-use the `host_row` partial that already exists so the live-refresh swap is a clean OOB swap, not a full table re-render. + +--- + ## Future / unscheduled > Items here have a plausible use case but no confirmed need. They live diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 80fd24d..d72d394 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%}.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 .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}.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}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.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-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}.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-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}.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-center{text-align:center}.text-right{text-align:right}.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-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-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%}.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 .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}.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}.min-h-screen{min-height:100vh}.w-16{width:4rem}.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-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}.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} diff --git a/web/templates/pages/add_host.html b/web/templates/pages/add_host.html index 8a20cf3..7dc8b08 100644 --- a/web/templates/pages/add_host.html +++ b/web/templates/pages/add_host.html @@ -22,6 +22,45 @@ {{end}} + {{if $page.OutstandingTokens}} +
+
+

Outstanding install tokens

+ closed the install snippet tab? regenerate to get a fresh URL +
+ + + + + + + + + + + + {{range $page.OutstandingTokens}} + + + + + + + + {{end}} + +
idrepocreatedexpires
{{.ShortHash}}…{{if .RepoURL}}{{.RepoURL}}{{else}}{{end}}{{.CreatedAt | relTime}}{{.ExpiresAt | relTime}} +
+ +
+
+ +
+
+
+ {{end}} +
diff --git a/web/templates/pages/alerts.html b/web/templates/pages/alerts.html index 0b32e9d..10a8dbc 100644 --- a/web/templates/pages/alerts.html +++ b/web/templates/pages/alerts.html @@ -141,7 +141,7 @@
{{else}} {{range $page.Alerts}} - {{template "alert_row" (dict "Alert" . "HostNames" $page.HostNames "Filter" $page.Filter)}} + {{template "alert_row" (dict "Alert" . "HostNames" $page.HostNames "Usernames" $page.Usernames "Filter" $page.Filter)}} {{end}} {{end}} diff --git a/web/templates/pages/bootstrap.html b/web/templates/pages/bootstrap.html new file mode 100644 index 0000000..5e3c744 --- /dev/null +++ b/web/templates/pages/bootstrap.html @@ -0,0 +1,64 @@ +{{define "title"}}Welcome · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+ +
+
+
restic-manager
+
+ +

+ Create the first administrator +

+

+ This server has no users yet. The account you create here is the + initial administrator. This page is only available until that + account exists. +

+ + {{if $page.Error}} +
+ {{$page.Error}} +
+ {{end}} + + +
+ + +
+
+ + +
+
+ + +
+ + + +
+

+ Lost the browser session mid-flow? The bootstrap token is also + printed in the server logs and can be POSTed to + /api/bootstrap. +

+
+
+ +
+ restic-manager {{.Version}} +
+ +
+{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index a821817..b4ffb23 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -121,21 +121,63 @@ {{end}} {{/* ---------- hosts table ---------- */}} + {{$f := $page.Filter}} + {{$sortURL := $page.SortURL}}

Hosts

{{$page.ShownCount}} of {{$page.HostCount}}
+
+ {{/* Filter row (NS-04): GET /, every input is a hidden field + for the filters not currently being edited so submit + merges rather than clobbers state. */}} +
+ + + + {{if $f.Tag}}{{end}} + {{if ne $f.Sort "name"}}{{end}} + {{if eq $f.Dir "desc"}}{{end}} + + {{if or $f.Search $f.Status $f.RepoStatus}} + clear + {{end}} +
+ {{/* Tag chip-row — only renders when at least one tag exists in the fleet. Active tag is highlighted; clicking the active tag clears the filter. The "All" pill is shown in the active state when no tag filter is set. */}} {{if $page.KnownTags}}
- filter + tag All {{range $page.KnownTags}} {{$t := .}} @@ -144,15 +186,24 @@
{{end}} -
+ {{/* Live-poll wrapper (NS-04, mirrors the alerts pattern). hx-get + refetches with the current filter pinned; hx-select grabs only + this same div from the response so the surrounding chrome + doesn't flash. The toggle persists in localStorage so a + refreshed tab honours the operator's previous choice. */}} +
-
Host
-
OS · arch
-
Last backup
-
Repo size
-
Snapshots
+ + + + +
Alerts
Tags
diff --git a/web/templates/pages/host_detail.html b/web/templates/pages/host_detail.html index 9d87f47..fdd9890 100644 --- a/web/templates/pages/host_detail.html +++ b/web/templates/pages/host_detail.html @@ -110,10 +110,21 @@
Danger zone

- Removes the host record. The repo data on the rest-server is left intact — - you delete that yourself. + Removes the host record and everything attached to it + (schedules, source groups, jobs, snapshots metadata, alerts). + The agent's bearer is revoked, so a re-installed instance + comes back through the normal pending-host accept flow. + The repo data on the rest-server is left intact — you delete + that yourself.

- +
+ + +
diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html index 88bfa13..9d9e755 100644 --- a/web/templates/pages/host_repo.html +++ b/web/templates/pages/host_repo.html @@ -8,6 +8,31 @@
+ {{/* ---------- Repo status (NS-03) ---------- */}} + {{if eq $host.RepoStatus "init_failed"}} +
+
+
Repo unreachable
+
+ +
+
+
+ The last init / probe against this host's repo failed. Fix the + credentials below and save (the save kicks a fresh probe), or + click Retry probe if you've changed + something out-of-band. +
+ {{if $host.RepoStatusError}} +
{{$host.RepoStatusError}}
+ {{end}} +
+ {{else if eq $host.RepoStatus "ready"}} +
✓ repo reachable with current credentials
+ {{end}} + {{/* ---------- Connection ---------- */}}

Connection

@@ -269,7 +294,7 @@ onsubmit="return confirm('Re-initialise the repo on host "{{$host.Name}}"? Existing snapshots are lost if the rest-server allows the wipe; restic refuses if it sees a config file already there.');"> diff --git a/web/templates/pages/host_restore.html b/web/templates/pages/host_restore.html index 0f6fed6..39c0557 100644 --- a/web/templates/pages/host_restore.html +++ b/web/templates/pages/host_restore.html @@ -175,12 +175,11 @@ + placeholder="/root/rm-restore/<job-id>/" />
- $HOME resolves to the agent user's home; - <job-id> is substituted on dispatch. - Edit if you want a specific directory. + Edit if you want a specific directory + (<job-id> is substituted).
diff --git a/web/templates/partials/alert_row.html b/web/templates/partials/alert_row.html index 7cabdc1..0ac05db 100644 --- a/web/templates/partials/alert_row.html +++ b/web/templates/partials/alert_row.html @@ -1,6 +1,7 @@ {{define "alert_row"}} {{$a := .Alert}} {{$hostNames := .HostNames}} +{{$usernames := .Usernames}} {{$filter := .Filter}} {{$status := alertStatus $a.ResolvedAt $a.AcknowledgedAt}} @@ -81,7 +82,12 @@ {{else if eq $status "acknowledged"}} - ack'd{{if $a.AcknowledgedBy}} by {{deref $a.AcknowledgedBy}}{{end}} · {{relTime $a.AcknowledgedAt}} + {{$ackedBy := ""}} + {{if $a.AcknowledgedBy}} + {{$id := deref $a.AcknowledgedBy}} + {{if index $usernames $id}}{{$ackedBy = index $usernames $id}}{{else}}{{$ackedBy = $id}}{{end}} + {{end}} + ack'd{{if $ackedBy}} by {{$ackedBy}}{{end}} · {{relTime $a.AcknowledgedAt}}
{{if $qs}}{{end}}