diff --git a/CLAUDE.md b/CLAUDE.md index 690645d..b6ec3c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,8 +81,29 @@ RM_COOKIE_SECURE=false \ ./bin/restic-manager-server >> $HOME/smoke/server.log 2>&1 & ``` -A `make smoke-deploy` target that bundles all of this would be a -good follow-up. +## Smoke server: use the Make targets, not raw `nohup` + +The smoke server runs as a transient `systemd --user` unit named +`restic-manager-smoke.service` so it survives any sandbox or +process-group boundary that would otherwise SIGTERM a backgrounded +process. Use the Make targets: + +``` +make smoke-restart # rebuild server + (re)launch as systemd --user unit +make smoke-status # systemctl --user status +make smoke-logs # tail $HOME/smoke/server.log +make smoke-stop # stop the unit +make smoke-deploy # full rebuild + restage agent assets + restart +``` + +`./bin/restic-manager-server &` from inside a Bash tool call gets +reaped when the tool exits — don't do that. If the unit fails to +start: `systemctl --user status restic-manager-smoke` and +`$HOME/smoke/server.log` have the diagnosis. + +`smoke-deploy` does NOT touch `/usr/local/bin/restic-manager-agent` +on this dev box; if your change requires the live agent here to +update, run the agent restage block above by hand. ## Migrations: prefer column-level ALTERs over table rebuilds diff --git a/Makefile b/Makefile index 2957229..767a534 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,18 @@ TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/downlo TAILWIND_INPUT := web/styles/input.css TAILWIND_OUTPUT := web/static/css/styles.css -.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch setup hooks +.PHONY: help build server agent test test-race lint fmt tidy clean run-server run-agent docker release tailwind tailwind-watch setup hooks smoke-restart smoke-stop smoke-status smoke-logs smoke-deploy + +# ---- smoke-env tooling ------------------------------------------------- +# The smoke server runs as a transient user-systemd unit so it survives +# bash-tool boundaries and reboots-of-the-shell. Use `make smoke-restart` +# any time you've rebuilt the server. `make smoke-deploy` is the full +# rebuild + restage + restart workflow described in CLAUDE.md. +SMOKE_UNIT := restic-manager-smoke +SMOKE_DATA_DIR := $(HOME)/smoke/data +SMOKE_LOG_FILE := $(HOME)/smoke/server.log +SMOKE_BASE_URL := http://127.0.0.1:8080 +SMOKE_LISTEN := :8080 help: @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' @@ -94,6 +105,48 @@ docker: ## Build the server Docker image --build-arg DATE=$(DATE) \ -t $(DOCKER_IMAGE):$(DOCKER_TAG) . +smoke-restart: server ## (Re)start the smoke server as a transient user-systemd unit + @systemctl --user reset-failed $(SMOKE_UNIT) >/dev/null 2>&1 || true + @systemctl --user stop $(SMOKE_UNIT) >/dev/null 2>&1 || true + @echo "==> launching $(SMOKE_UNIT)" + systemd-run --user --unit=$(SMOKE_UNIT) \ + --setenv=RM_LISTEN=$(SMOKE_LISTEN) \ + --setenv=RM_DATA_DIR=$(SMOKE_DATA_DIR) \ + --setenv=RM_BASE_URL=$(SMOKE_BASE_URL) \ + --setenv=RM_SECRET_KEY_FILE=$(SMOKE_DATA_DIR)/secret.key \ + --setenv=RM_COOKIE_SECURE=false \ + --property=StandardOutput=append:$(SMOKE_LOG_FILE) \ + --property=StandardError=append:$(SMOKE_LOG_FILE) \ + --property=Restart=on-failure \ + $(PWD)/$(SERVER_BIN) + @for i in 1 2 3 4 5; do \ + curl -fsS -o /dev/null $(SMOKE_BASE_URL)/api/version 2>/dev/null && \ + { echo "==> smoke server up: $$(curl -s $(SMOKE_BASE_URL)/api/version)"; exit 0; }; \ + sleep 1; \ + done; \ + echo "!! smoke server did not respond on $(SMOKE_BASE_URL) — check $(SMOKE_LOG_FILE)" >&2; \ + systemctl --user status --no-pager $(SMOKE_UNIT) || true; \ + exit 1 + +smoke-stop: ## Stop the smoke server + systemctl --user stop $(SMOKE_UNIT) || true + @systemctl --user reset-failed $(SMOKE_UNIT) >/dev/null 2>&1 || true + +smoke-status: ## Show status of the smoke server + @systemctl --user status --no-pager $(SMOKE_UNIT) 2>&1 | head -20 || true + +smoke-logs: ## Tail the smoke server log + tail -50 $(SMOKE_LOG_FILE) + +smoke-deploy: build smoke-restart ## Rebuild + restage agent into smoke + restart server (full per-CLAUDE.md cycle) + @echo "==> restaging agent + install assets into $(SMOKE_DATA_DIR)" + cp $(AGENT_BIN) $(SMOKE_DATA_DIR)/agent-binaries/restic-manager-agent-linux-amd64 + cp deploy/install/install.sh $(SMOKE_DATA_DIR)/install/install.sh + cp deploy/install/install.ps1 $(SMOKE_DATA_DIR)/install/install.ps1 + cp deploy/install/restic-manager-agent.service $(SMOKE_DATA_DIR)/install/restic-manager-agent.service + @echo "==> NOTE: this dev box's installed agent at /usr/local/bin/restic-manager-agent is NOT updated by this target." + @echo " Run the agent restage block in CLAUDE.md if your change touches agent code or the unit file." + release: ## Cross-compile for all supported platforms @mkdir -p $(BIN_DIR) @for target in linux/amd64 linux/arm64 windows/amd64; do \ diff --git a/cmd/server/main.go b/cmd/server/main.go index dcd0d38..b79d201 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -92,7 +92,7 @@ func run() error { notifHub := notification.NewHub(st, aead, cfg.BaseURL) alertEngine := alert.NewEngine(st, notifHub) - updateWatcher := ws.NewUpdateWatcher(st, alertEngine) + updateWatcher := ws.NewUpdateWatcher(st, alertEngine, jobHub) renderer, err := ui.New() if err != nil { diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 17ecc7a..c2d90c3 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -195,7 +195,9 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/sources", s.handleUIHostSources) r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) + r.Get("/hosts/{id}/jobs", s.handleUIHostJobs) r.Get("/hosts/{id}/repo", s.handleUIHostRepo) + r.Get("/hosts/{id}/repo/trend", s.handleUIRepoTrend) r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet) diff --git a/internal/server/http/ui_dashboard_sparkline_test.go b/internal/server/http/ui_dashboard_sparkline_test.go new file mode 100644 index 0000000..748bfc5 --- /dev/null +++ b/internal/server/http/ui_dashboard_sparkline_test.go @@ -0,0 +1,83 @@ +package http + +import ( + "context" + stdhttp "net/http" + "strings" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func getDashboard(t *testing.T, baseURL string, cookie *stdhttp.Cookie) string { + t.Helper() + client := &stdhttp.Client{ + CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + req, err := stdhttp.NewRequest("GET", baseURL+"/", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.AddCookie(cookie) + res, err := client.Do(req) + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Fatalf("GET /: want 200, got %d", res.StatusCode) + } + body := make([]byte, 0, 1<<20) + buf := make([]byte, 4096) + for { + n, rerr := res.Body.Read(buf) + body = append(body, buf[:n]...) + if rerr != nil { + break + } + } + return string(body) +} + +func TestDashboard_HostRowSparklineRendersWithHistory(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "h-spark") + ctx := context.Background() + + // Two history points → polyline must render. + for i, day := range []string{"2026-05-05", "2026-05-06"} { + v := int64(100 + i*50) + if err := st.UpsertHostRepoStatsHistory(ctx, hostID, day, + store.HostRepoStats{TotalSizeBytes: &v}, time.Now().UTC()); err != nil { + t.Fatalf("upsert %s: %v", day, err) + } + } + + body := getDashboard(t, baseURL, cookie) + if !strings.Contains(body, `class="repo-sparkline"`) { + t.Errorf("expected sparkline SVG in dashboard body (class=repo-sparkline missing)") + } + if !strings.Contains(body, ` in dashboard body") + } +} + +func TestDashboard_HostRowSparklineEmptyState(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + makeHost(t, st, "h-empty") + + body := getDashboard(t, baseURL, cookie) + if !strings.Contains(body, `class="repo-sparkline"`) { + t.Errorf("expected sparkline SVG element on dashboard") + } + if !strings.Contains(body, `>—<`) { + t.Errorf("expected em-dash placeholder in empty sparkline cell") + } +} diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index c569c27..e0c4515 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -5,8 +5,10 @@ import ( "encoding/base64" "encoding/json" "errors" + "html/template" "io/fs" "log/slog" + "math" stdhttp "net/http" "net/url" "sort" @@ -24,6 +26,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/version" + "gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline" "gitea.dcglab.co.uk/steve/restic-manager/web" ) @@ -196,6 +199,10 @@ type dashboardHostRow struct { // TargetVersion is the server's build version, surfaced in the // chip's tooltip and label. TargetVersion string + // RepoSparklineSVG is a server-rendered inline SVG showing the + // 30-day repo-size trend. Empty-state SVG (em-dash) is returned + // when no history rows exist for the host. + RepoSparklineSVG template.HTML } // pickRunAllSchedule returns the ID of the single schedule whose @@ -296,6 +303,20 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) } } } + since := time.Now().UTC().AddDate(0, 0, -30) + pts, herr := s.deps.Store.ListHostRepoStatsHistory(r.Context(), h.ID, since) + if herr != nil { + slog.Warn("ui dashboard: list repo history", "host_id", h.ID, "err", herr) + } + sparkPoints := make([]float64, len(pts)) + for i, p := range pts { + if p.TotalSizeBytes == nil { + sparkPoints[i] = math.NaN() + } else { + sparkPoints[i] = float64(*p.TotalSizeBytes) + } + } + row.RepoSparklineSVG = sparkline.RenderSparkline(sparkPoints, 88, 20) rows = append(rows, row) } diff --git a/internal/server/http/ui_host_jobs.go b/internal/server/http/ui_host_jobs.go new file mode 100644 index 0000000..dd280a9 --- /dev/null +++ b/internal/server/http/ui_host_jobs.go @@ -0,0 +1,47 @@ +package http + +import ( + "log/slog" + stdhttp "net/http" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// hostJobsPage is the page-data struct for /hosts/{id}/jobs. +type hostJobsPage struct { + hostChromeData + Jobs []store.Job +} + +// handleUIHostJobs renders the per-host jobs list. Read-only — no +// actions, just a click-through to the existing /jobs/{id} detail +// page for any row. +func (s *Server) handleUIHostJobs(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + + jobs, err := s.deps.Store.ListJobsByHost(r.Context(), host.ID, 100) + if err != nil { + slog.Error("ui host jobs: list", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + page := hostJobsPage{ + hostChromeData: s.loadHostChrome(r, *host, "jobs", "jobs"), + Jobs: jobs, + } + view := s.baseView(r, u) + view.Title = host.Name + " jobs · restic-manager" + view.Page = page + if err := s.deps.UI.Render(w, "host_jobs", view); err != nil { + slog.Error("ui: render host_jobs", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} diff --git a/internal/server/http/ui_host_jobs_test.go b/internal/server/http/ui_host_jobs_test.go new file mode 100644 index 0000000..9df1e54 --- /dev/null +++ b/internal/server/http/ui_host_jobs_test.go @@ -0,0 +1,85 @@ +package http + +import ( + "context" + "io" + stdhttp "net/http" + "strings" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func TestUIHostJobs_RendersList(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "h-jobs-render") + + // Two jobs with distinct kinds + statuses. + now := time.Now().UTC() + ctx := context.Background() + if err := st.CreateJob(ctx, store.Job{ + ID: "01HZZZZZZZZZZZZZZZZZZZZZ10", HostID: hostID, Kind: "backup", + ActorKind: "user", CreatedAt: now.Add(-time.Hour), + }); err != nil { + t.Fatalf("create job: %v", err) + } + if err := st.MarkJobFinished(ctx, "01HZZZZZZZZZZZZZZZZZZZZZ10", "succeeded", 0, nil, "", now.Add(-time.Hour+time.Minute)); err != nil { + t.Fatalf("finish job: %v", err) + } + if err := st.CreateJob(ctx, store.Job{ + ID: "01HZZZZZZZZZZZZZZZZZZZZZ11", HostID: hostID, Kind: "prune", + ActorKind: "schedule", CreatedAt: now, + }); err != nil { + t.Fatalf("create job: %v", err) + } + if err := st.MarkJobFinished(ctx, "01HZZZZZZZZZZZZZZZZZZZZZ11", "failed", 1, nil, "boom", now.Add(time.Minute)); err != nil { + t.Fatalf("finish job: %v", err) + } + + body := getHostJobsPage(t, baseURL, hostID, cookie) + for _, want := range []string{"backup", "prune", "succeeded", "failed", "schedule", "user", `class="jobs-row`} { + if !strings.Contains(body, want) { + t.Errorf("expected %q in body, missing", want) + } + } +} + +func TestUIHostJobs_EmptyState(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "h-jobs-empty") + + body := getHostJobsPage(t, baseURL, hostID, cookie) + if !strings.Contains(body, "No jobs yet.") { + t.Error("expected empty-state heading") + } +} + +// getHostJobsPage fetches /hosts/{id}/jobs and returns the body string. +func getHostJobsPage(t *testing.T, baseURL, hostID string, cookie *stdhttp.Cookie) string { + t.Helper() + client := &stdhttp.Client{ + CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + req, err := stdhttp.NewRequest("GET", baseURL+"/hosts/"+hostID+"/jobs", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.AddCookie(cookie) + res, err := client.Do(req) + if err != nil { + t.Fatalf("GET /hosts/%s/jobs: %v", hostID, err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Fatalf("GET /hosts/%s/jobs: want 200, got %d", hostID, res.StatusCode) + } + raw, _ := io.ReadAll(res.Body) + return string(raw) +} diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index 461a1ab..a630831 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -1,9 +1,12 @@ package http import ( + "context" "encoding/json" "errors" + "html/template" "log/slog" + "math" stdhttp "net/http" "strconv" "strings" @@ -13,6 +16,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" + "gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline" ) // ui_repo.go — HTML form-driven repo-tab handlers (connection, @@ -27,6 +31,15 @@ import ( // POST /hosts/{id}/admin-credentials — admin (prune) creds // POST /hosts/{id}/admin-credentials/delete — clear admin creds +// repoTrendView is the data the repo_size_chart partial needs. +// HostID + Range round-trip through the htmx range pills; ChartSVG +// is pre-rendered server-side so the partial is just a wrapper. +type repoTrendView struct { + HostID string + Range string + ChartSVG template.HTML +} + // repoStatsView is a flat, pre-dereferenced projection of // store.HostRepoStats for use in templates. Nil pointer fields are // collapsed to zero/false and accompanied by a Has* sentinel so the @@ -74,6 +87,10 @@ type hostRepoPage struct { // Nil when no row exists yet (fresh hosts). StatsView *repoStatsView + // Trend holds the pre-rendered chart fragment data for the + // 30/90/365-day repo-size + snapshot-count overlay chart. + Trend repoTrendView + // Snapshots-by-tag — map[group_name]count, plus an "untagged" row. SnapshotsByTag map[string]int UntaggedSnapshots int @@ -225,9 +242,52 @@ func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRep } } } + p.Trend = s.buildRepoTrendView(r.Context(), host.ID, "30d") + return p, nil } +// buildRepoTrendView builds the chart-partial data for a host. Used +// both by the page-load (initial 30d render) and the htmx fragment +// endpoint (range switching). An invalid rangeKey falls back to "30d". +func (s *Server) buildRepoTrendView(ctx context.Context, hostID, rangeKey string) repoTrendView { + days := 30 + switch rangeKey { + case "90d": + days = 90 + case "1y": + days = 365 + default: + rangeKey = "30d" + } + since := time.Now().UTC().AddDate(0, 0, -days) + pts, err := s.deps.Store.ListHostRepoStatsHistory(ctx, hostID, since) + if err != nil { + slog.Warn("ui repo trend: list history", "host_id", hostID, "err", err) + } + sizes := make([]float64, len(pts)) + counts := make([]float64, len(pts)) + dayList := make([]time.Time, len(pts)) + for i, p := range pts { + dayList[i] = p.Day + if p.TotalSizeBytes == nil { + sizes[i] = math.NaN() + } else { + sizes[i] = float64(*p.TotalSizeBytes) + } + if p.SnapshotCount == nil { + counts[i] = math.NaN() + } else { + counts[i] = float64(*p.SnapshotCount) + } + } + chartSVG := sparkline.RenderChart([]sparkline.Series{ + {Name: "size", Stroke: "#3b82f6", Axis: sparkline.AxisLeft, Format: sparkline.FormatBytes, Points: sizes}, + {Name: "snapshots", Stroke: "#f59e0b", Axis: sparkline.AxisRight, Format: sparkline.FormatCount, Points: counts}, + }, dayList, sparkline.ChartOpts{Width: 640, Height: 220}) + return repoTrendView{HostID: hostID, Range: rangeKey, ChartSVG: chartSVG} +} + func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { diff --git a/internal/server/http/ui_repo_trend.go b/internal/server/http/ui_repo_trend.go new file mode 100644 index 0000000..4c7786a --- /dev/null +++ b/internal/server/http/ui_repo_trend.go @@ -0,0 +1,25 @@ +// ui_repo_trend.go — htmx fragment endpoint for the repo-page +// trend chart. Returns just the chart partial wrapped in +//
so htmx can outerHTML-swap it. +// +// GET /hosts/{id}/repo/trend?range=30d|90d|1y +package http + +import ( + stdhttp "net/http" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) handleUIRepoTrend(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + hostID := chi.URLParam(r, "id") + view := s.baseView(r, u) + view.Page = s.buildRepoTrendView(r.Context(), hostID, r.URL.Query().Get("range")) + if err := s.deps.UI.RenderPartial(w, "repo_size_chart", view); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + } +} diff --git a/internal/server/http/ui_repo_trend_test.go b/internal/server/http/ui_repo_trend_test.go new file mode 100644 index 0000000..c33359e --- /dev/null +++ b/internal/server/http/ui_repo_trend_test.go @@ -0,0 +1,123 @@ +package http + +import ( + "context" + stdhttp "net/http" + "strings" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func getTrend(t *testing.T, baseURL, hostID, rangeKey string, cookie *stdhttp.Cookie) string { + t.Helper() + client := &stdhttp.Client{ + CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }, + } + url := baseURL + "/hosts/" + hostID + "/repo/trend" + if rangeKey != "" { + url += "?range=" + rangeKey + } + req, err := stdhttp.NewRequest("GET", url, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.AddCookie(cookie) + res, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Fatalf("GET %s: want 200, got %d", url, res.StatusCode) + } + body := make([]byte, 0, 1<<20) + buf := make([]byte, 4096) + for { + n, rerr := res.Body.Read(buf) + body = append(body, buf[:n]...) + if rerr != nil { + break + } + } + return string(body) +} + +func TestUIRepoTrend_30dRange(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "h-trend") + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 5; i++ { + day := now.AddDate(0, 0, -i).Format("2006-01-02") + v := int64(1000 + i*100) + c := int64(10 + i) + if err := st.UpsertHostRepoStatsHistory(ctx, hostID, day, + store.HostRepoStats{TotalSizeBytes: &v, SnapshotCount: &c}, now); err != nil { + t.Fatalf("seed %s: %v", day, err) + } + } + + body := getTrend(t, baseURL, hostID, "30d", cookie) + if !strings.Contains(body, `class="repo-trend-chart"`) { + t.Errorf("expected repo-trend-chart SVG in fragment") + } + if !strings.Contains(body, `id="repo-trend-chart"`) { + t.Errorf("expected outer wrapper id=repo-trend-chart") + } + if !strings.Contains(body, `data-range="30d"`) { + t.Errorf("expected data-range=30d") + } +} + +func TestUIRepoTrend_InvalidRangeFallsBackTo30d(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "h-trend2") + + body := getTrend(t, baseURL, hostID, "banana", cookie) + if !strings.Contains(body, `data-range="30d"`) { + t.Errorf("expected data-range=30d on invalid range fallback") + } +} + +// TestUIRepoPageRendersTrendPanel — full-page render path: seed 3 +// history rows, fetch /hosts/{id}/repo, assert the Trend panel with +// SVG chart ID, class, and heading text appear embedded in the page. +func TestUIRepoPageRendersTrendPanel(t *testing.T) { + t.Parallel() + _, baseURL, st := newTestServerWithUI(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "h-trend-page") + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 3; i++ { + day := now.AddDate(0, 0, -i).Format("2006-01-02") + v := int64(2000 + i*200) + c := int64(20 + i) + if err := st.UpsertHostRepoStatsHistory(ctx, hostID, day, + store.HostRepoStats{TotalSizeBytes: &v, SnapshotCount: &c}, now); err != nil { + t.Fatalf("seed %s: %v", day, err) + } + } + + body := getRepoPage(t, baseURL, hostID, cookie) + + if !strings.Contains(body, `id="repo-trend-chart"`) { + t.Errorf("expected id=\"repo-trend-chart\" in full-page render") + } + if !strings.Contains(body, `class="repo-trend-chart"`) { + t.Errorf("expected class=\"repo-trend-chart\" in full-page render") + } + if !strings.Contains(body, ">Trend<") { + t.Errorf("expected panel heading '>Trend<' in full-page render") + } +} diff --git a/internal/server/ui/funcs.go b/internal/server/ui/funcs.go index ebf6881..33d1260 100644 --- a/internal/server/ui/funcs.go +++ b/internal/server/ui/funcs.go @@ -75,6 +75,28 @@ func funcMap() template.FuncMap { return *p }, "sub": func(a, b int) int { return a - b }, + // durationHuman formats the elapsed time between two *time.Time + // values as a short human string: "350ms", "4.2s", "2m 15s", + // "1h 4m". Returns "—" when either pointer is nil. + "durationHuman": func(start, end *time.Time) string { + if start == nil || end == nil { + return "—" + } + d := end.Sub(*start) + if d < 0 { + d = -d + } + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) + }, // joinComma joins a slice with ", ". Used by the schedule list // to render retention summaries. "joinComma": func(parts []string) string { return strings.Join(parts, ", ") }, diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 45e5af7..f072ae2 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -110,6 +110,7 @@ func New() (*Renderer, error) { "templates/partials/crit_banner.html", "templates/partials/fleet_update_inner.html", "templates/partials/host_update_chip.html", + "templates/partials/repo_size_chart.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") diff --git a/internal/server/ws/handler.go b/internal/server/ws/handler.go index 5a0473c..4fd0e4c 100644 --- a/internal/server/ws/handler.go +++ b/internal/server/ws/handler.go @@ -339,6 +339,10 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E } else { slog.Info("ws: repo stats refreshed", "host_id", hostID) } + day := time.Now().UTC().Format("2006-01-02") + if err := deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day, patch, time.Now().UTC()); err != nil { + slog.Warn("ws: upsert host repo stats history", "host_id", hostID, "err", err) + } case api.MsgCommandResult: // TODO(P2): persist command.result acks for "did the agent diff --git a/internal/server/ws/handler_test.go b/internal/server/ws/handler_test.go index 819a812..1bd2088 100644 --- a/internal/server/ws/handler_test.go +++ b/internal/server/ws/handler_test.go @@ -133,3 +133,42 @@ func TestRepoStatsReportPartialUpdate(t *testing.T) { t.Errorf("LastCheckStatus: got %q want ok", got.LastCheckStatus) } } + +func TestRepoStatsReportWritesHistoryRow(t *testing.T) { + t.Parallel() + s := openWSTestStore(t) + ctx := context.Background() + + const hostID = "h-stats-history" + seedHostWS(t, s, hostID) + + payload := api.RepoStatsPayload{ + TotalSizeBytes: int64ptrWS(12345), + SnapshotCount: int64ptrWS(7), + } + env, err := api.Marshal(api.MsgRepoStats, "", payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + deps := HandlerDeps{Store: s} + dispatchAgentMessage(ctx, nil, hostID, env, deps) + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(pts) != 1 { + t.Fatalf("want 1 history row, got %d", len(pts)) + } + wantDay := time.Now().UTC().Format("2006-01-02") + if got := pts[0].Day.Format("2006-01-02"); got != wantDay { + t.Errorf("day: want %s, got %s", wantDay, got) + } + if pts[0].TotalSizeBytes == nil || *pts[0].TotalSizeBytes != 12345 { + t.Errorf("TotalSizeBytes: want 12345, got %v", pts[0].TotalSizeBytes) + } + if pts[0].SnapshotCount == nil || *pts[0].SnapshotCount != 7 { + t.Errorf("SnapshotCount: want 7, got %v", pts[0].SnapshotCount) + } +} diff --git a/internal/server/ws/update_watch.go b/internal/server/ws/update_watch.go index be2fef8..afdc82f 100644 --- a/internal/server/ws/update_watch.go +++ b/internal/server/ws/update_watch.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -29,6 +30,7 @@ type AlertRaiser interface { type UpdateWatcher struct { store *store.Store alerts AlertRaiser + jobHub *JobHub // optional — if nil, no fan-out to browser streams mu sync.Mutex entries map[string]*updateEntry // hostID → entry @@ -46,10 +48,11 @@ type updateEntry struct { // NewUpdateWatcher builds an unstarted watcher. Call Run in a goroutine // to start the periodic sweep. -func NewUpdateWatcher(st *store.Store, alerts AlertRaiser) *UpdateWatcher { +func NewUpdateWatcher(st *store.Store, alerts AlertRaiser, jobHub *JobHub) *UpdateWatcher { return &UpdateWatcher{ store: st, alerts: alerts, + jobHub: jobHub, entries: make(map[string]*updateEntry), tickPeriod: 5 * time.Second, } @@ -95,6 +98,7 @@ func (w *UpdateWatcher) OnHello(ctx context.Context, hostID, agentVersion, targe if err := w.store.MarkJobFinished(ctx, jobID, "succeeded", 0, nil, "", now); err != nil { slog.Warn("ws update watcher: mark succeeded", "job_id", jobID, "host_id", hostID, "err", err) } + w.publishJobFinished(jobID, api.JobSucceeded, 0, "", now) if w.alerts != nil { w.alerts.ResolveUpdateFailed(ctx, hostID, now) } @@ -144,8 +148,37 @@ func (w *UpdateWatcher) sweep(ctx context.Context, now time.Time) { if err := w.store.MarkJobFinished(ctx, x.jobID, "failed", -1, nil, errMsg, stamp); err != nil { slog.Warn("ws update watcher: mark failed", "job_id", x.jobID, "host_id", x.hostID, "err", err) } + w.publishJobFinished(x.jobID, api.JobFailed, -1, errMsg, stamp) if w.alerts != nil { w.alerts.RaiseUpdateFailed(ctx, x.hostID, x.jobID, reason, stamp) } } } + +// publishJobFinished pushes a synthetic job.finished envelope into the +// JobHub so any browser still streaming this job sees it terminate. +// The agent itself exits before it can send job.finished (it has to — +// it's about to relaunch into the new binary), so without this fan-out +// the /jobs/{id} page hangs until reload. +// +// Best-effort: if the hub is nil or the envelope can't be marshalled +// we log and move on — the DB-side state is already correct, this is +// purely a UI wake-up. +func (w *UpdateWatcher) publishJobFinished(jobID string, status api.JobStatus, exitCode int, errMsg string, finishedAt time.Time) { + if w.jobHub == nil { + return + } + payload := api.JobFinishedPayload{ + JobID: jobID, + Status: status, + ExitCode: exitCode, + FinishedAt: finishedAt, + Error: errMsg, + } + env, err := api.Marshal(api.MsgJobFinished, "", payload) + if err != nil { + slog.Warn("ws update watcher: marshal synthetic job.finished", "job_id", jobID, "err", err) + return + } + w.jobHub.Broadcast(jobID, env) +} diff --git a/internal/server/ws/update_watch_test.go b/internal/server/ws/update_watch_test.go index 4081501..845b986 100644 --- a/internal/server/ws/update_watch_test.go +++ b/internal/server/ws/update_watch_test.go @@ -8,6 +8,7 @@ import ( "github.com/oklog/ulid/v2" + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -50,7 +51,7 @@ func TestUpdateWatcherOnHelloSuccess(t *testing.T) { jobID := seedJob(t, st, hostID) a := &fakeAlerts{} - w := NewUpdateWatcher(st, a) + w := NewUpdateWatcher(st, a, nil) w.Track(jobID, hostID) w.OnHello(context.Background(), hostID, "v2", "v2") @@ -83,7 +84,7 @@ func TestUpdateWatcherTimeout(t *testing.T) { jobID := seedJob(t, st, hostID) a := &fakeAlerts{} - w := NewUpdateWatcher(st, a) + w := NewUpdateWatcher(st, a, nil) w.Track(jobID, hostID) time.Sleep(80 * time.Millisecond) @@ -113,7 +114,7 @@ func TestUpdateWatcherMismatchedVersionNoOp(t *testing.T) { jobID := seedJob(t, st, hostID) a := &fakeAlerts{} - w := NewUpdateWatcher(st, a) + w := NewUpdateWatcher(st, a, nil) w.Track(jobID, hostID) w.OnHello(context.Background(), hostID, "v1", "v2") @@ -140,7 +141,7 @@ func TestUpdateWatcherHelloAfterTimeoutIsNoOp(t *testing.T) { jobID := seedJob(t, st, hostID) a := &fakeAlerts{} - w := NewUpdateWatcher(st, a) + w := NewUpdateWatcher(st, a, nil) w.Track(jobID, hostID) time.Sleep(80 * time.Millisecond) @@ -159,3 +160,71 @@ func TestUpdateWatcherHelloAfterTimeoutIsNoOp(t *testing.T) { t.Fatalf("late hello triggered ResolveUpdateFailed: %v", a.resolved) } } + +func TestUpdateWatcherOnHelloBroadcastsJobFinished(t *testing.T) { + st := openWSTestStore(t) + hostID := ulid.Make().String() + seedHostWS(t, st, hostID) + jobID := seedJob(t, st, hostID) + + hub := NewJobHub() + sub := hub.Register(jobID) + defer sub.unregister() + + w := NewUpdateWatcher(st, &fakeAlerts{}, hub) + w.Track(jobID, hostID) + w.OnHello(context.Background(), hostID, "v2", "v2") + + select { + case env := <-sub.ch: + if env.Type != api.MsgJobFinished { + t.Fatalf("envelope type: got %q want %q", env.Type, api.MsgJobFinished) + } + var p api.JobFinishedPayload + if err := env.UnmarshalPayload(&p); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if p.JobID != jobID || p.Status != api.JobSucceeded { + t.Fatalf("payload: got %+v", p) + } + case <-time.After(time.Second): + t.Fatal("expected synthetic job.finished broadcast, got nothing") + } +} + +func TestUpdateWatcherTimeoutBroadcastsJobFinished(t *testing.T) { + prev := updateTimeout + updateTimeout = 50 * time.Millisecond + t.Cleanup(func() { updateTimeout = prev }) + + st := openWSTestStore(t) + hostID := ulid.Make().String() + seedHostWS(t, st, hostID) + jobID := seedJob(t, st, hostID) + + hub := NewJobHub() + sub := hub.Register(jobID) + defer sub.unregister() + + w := NewUpdateWatcher(st, &fakeAlerts{}, hub) + w.Track(jobID, hostID) + + time.Sleep(80 * time.Millisecond) + w.sweep(context.Background(), time.Now()) + + select { + case env := <-sub.ch: + if env.Type != api.MsgJobFinished { + t.Fatalf("envelope type: got %q want %q", env.Type, api.MsgJobFinished) + } + var p api.JobFinishedPayload + if err := env.UnmarshalPayload(&p); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if p.JobID != jobID || p.Status != api.JobFailed { + t.Fatalf("payload: got %+v", p) + } + case <-time.After(time.Second): + t.Fatal("expected synthetic job.finished broadcast, got nothing") + } +} diff --git a/internal/store/host_repo_stats.go b/internal/store/host_repo_stats.go index 9889b29..681788f 100644 --- a/internal/store/host_repo_stats.go +++ b/internal/store/host_repo_stats.go @@ -211,6 +211,21 @@ func (s *Store) UpsertHostRepoStats(ctx context.Context, hostID string, patch Ho ); err != nil { return fmt.Errorf("store: upsert host_repo_stats: %w", err) } + + // Project total_size_bytes onto the dashboard's host row so the + // "Repo size" column and FleetSummary.SUM(repo_size_bytes) stay in + // sync with the latest report. We only write a non-nil size — a + // patch that doesn't carry a size (e.g. a prune-only ack) leaves + // the prior row value alone. + if cur.TotalSizeBytes != nil { + if _, err = tx.ExecContext(ctx, + `UPDATE hosts SET repo_size_bytes = ? WHERE id = ?`, + *cur.TotalSizeBytes, hostID, + ); err != nil { + return fmt.Errorf("store: project repo_size_bytes onto hosts row: %w", err) + } + } + return tx.Commit() } diff --git a/internal/store/host_repo_stats_history.go b/internal/store/host_repo_stats_history.go new file mode 100644 index 0000000..79a1433 --- /dev/null +++ b/internal/store/host_repo_stats_history.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// RepoStatsHistoryPoint is one (day, host) point for the trend chart. +// Both metric pointers may be nil — a row exists as soon as either +// metric was reported on that day. +type RepoStatsHistoryPoint struct { + Day time.Time // 00:00:00 UTC + TotalSizeBytes *int64 + SnapshotCount *int64 +} + +// UpsertHostRepoStatsHistory records the metrics carried by a +// repo.stats patch into the daily history table. Only the non-nil +// fields of patch.TotalSizeBytes / patch.SnapshotCount are written; +// existing values in the row are preserved via COALESCE so a +// prune-only or check-only patch does not null out a backup-time +// size we already captured earlier the same day. +func (s *Store) UpsertHostRepoStatsHistory( + ctx context.Context, hostID, day string, patch HostRepoStats, recordedAt time.Time, +) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO host_repo_stats_history + (host_id, day, total_size_bytes, snapshot_count, recorded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(host_id, day) DO UPDATE SET + total_size_bytes = COALESCE(excluded.total_size_bytes, host_repo_stats_history.total_size_bytes), + snapshot_count = COALESCE(excluded.snapshot_count, host_repo_stats_history.snapshot_count), + recorded_at = excluded.recorded_at`, + hostID, day, + nullableInt64(patch.TotalSizeBytes), + nullableInt64(patch.SnapshotCount), + recordedAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("store: upsert host_repo_stats_history: %w", err) + } + return nil +} + +// ListHostRepoStatsHistory returns all points for hostID with day +// >= since (UTC), ordered ascending. Pass time.Time{} to fetch the +// full history. +func (s *Store) ListHostRepoStatsHistory( + ctx context.Context, hostID string, since time.Time, +) ([]RepoStatsHistoryPoint, error) { + sinceStr := "" + if !since.IsZero() { + sinceStr = since.UTC().Format("2006-01-02") + } + rows, err := s.db.QueryContext(ctx, ` + SELECT day, total_size_bytes, snapshot_count + FROM host_repo_stats_history + WHERE host_id = ? AND day >= ? + ORDER BY day ASC`, + hostID, sinceStr) + if err != nil { + return nil, fmt.Errorf("store: list host_repo_stats_history: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []RepoStatsHistoryPoint + for rows.Next() { + var ( + dayStr string + total sql.NullInt64 + snapCnt sql.NullInt64 + ) + if err := rows.Scan(&dayStr, &total, &snapCnt); err != nil { + return nil, fmt.Errorf("store: scan history row: %w", err) + } + d, perr := time.Parse("2006-01-02", dayStr) + if perr != nil { + return nil, fmt.Errorf("store: parse history day %q: %w", dayStr, perr) + } + p := RepoStatsHistoryPoint{Day: d} + if total.Valid { + v := total.Int64 + p.TotalSizeBytes = &v + } + if snapCnt.Valid { + v := snapCnt.Int64 + p.SnapshotCount = &v + } + out = append(out, p) + } + if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("store: iterate history rows: %w", err) + } + return out, nil +} diff --git a/internal/store/host_repo_stats_history_test.go b/internal/store/host_repo_stats_history_test.go new file mode 100644 index 0000000..cd70b13 --- /dev/null +++ b/internal/store/host_repo_stats_history_test.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestHostRepoStatsHistory_PartialUpsertPreservesPriorValues(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-1" + seedHost(t, s, hostID) + + day := "2026-05-07" + now := time.Now().UTC() + + // 1. First write of the day: total_size only. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{TotalSizeBytes: int64ptr(100)}, now); err != nil { + t.Fatalf("upsert 1: %v", err) + } + + // 2. Second write: snapshot_count only — total_size MUST survive. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{SnapshotCount: int64ptr(7)}, now.Add(time.Minute)); err != nil { + t.Fatalf("upsert 2: %v", err) + } + + // 3. Third write: a prune-only patch (no size, no count). Both + // prior values must survive. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{}, now.Add(2*time.Minute)); err != nil { + t.Fatalf("upsert 3: %v", err) + } + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(pts) != 1 { + t.Fatalf("want 1 point, got %d", len(pts)) + } + p := pts[0] + if p.TotalSizeBytes == nil || *p.TotalSizeBytes != 100 { + t.Errorf("TotalSizeBytes: want 100, got %v", p.TotalSizeBytes) + } + if p.SnapshotCount == nil || *p.SnapshotCount != 7 { + t.Errorf("SnapshotCount: want 7, got %v", p.SnapshotCount) + } +} + +func TestHostRepoStatsHistory_OrderingAndSinceFilter(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-2" + seedHost(t, s, hostID) + + now := time.Now().UTC() + for i, day := range []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} { + v := int64(100 + i*10) + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{TotalSizeBytes: &v}, now); err != nil { + t.Fatalf("upsert %s: %v", day, err) + } + } + + all, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all) != 4 { + t.Fatalf("want 4 points, got %d", len(all)) + } + wantDays := []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} + for i, p := range all { + got := p.Day.Format("2006-01-02") + if got != wantDays[i] { + t.Errorf("point %d: want day %s, got %s", i, wantDays[i], got) + } + } + + since, _ := time.Parse("2006-01-02", "2026-05-03") + recent, err := s.ListHostRepoStatsHistory(ctx, hostID, since) + if err != nil { + t.Fatalf("list since: %v", err) + } + if len(recent) != 2 { + t.Fatalf("since 2026-05-03: want 2 points, got %d", len(recent)) + } +} + +func TestHostRepoStatsHistory_CascadeOnHostDelete(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-3" + seedHost(t, s, hostID) + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, "2026-05-07", + HostRepoStats{TotalSizeBytes: int64ptr(42)}, time.Now().UTC()); err != nil { + t.Fatalf("upsert: %v", err) + } + + if err := s.DeleteHost(ctx, hostID); err != nil { + t.Fatalf("delete host: %v", err) + } + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(pts) != 0 { + t.Fatalf("want 0 points after host delete, got %d", len(pts)) + } +} diff --git a/internal/store/jobs.go b/internal/store/jobs.go index f589c24..8493e5d 100644 --- a/internal/store/jobs.go +++ b/internal/store/jobs.go @@ -288,6 +288,87 @@ func (s *Store) HasJobOfKind(ctx context.Context, hostID, kind string) (bool, er return n > 0, nil } +// ListJobsByHost returns recent jobs for hostID, ordered by +// created_at DESC, limited to at most `limit` rows. limit ≤ 0 is +// treated as no limit. +func (s *Store) ListJobsByHost(ctx context.Context, hostID string, limit int) ([]Job, error) { + q := `SELECT id, host_id, kind, status, scheduled_id, source_group_id, + actor_kind, actor_id, started_at, finished_at, exit_code, + stats, error, created_at + FROM jobs + WHERE host_id = ? + ORDER BY created_at DESC` + args := []any{hostID} + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("store: list jobs by host: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []Job + for rows.Next() { + var ( + j Job + schedID sql.NullString + groupID sql.NullString + actorID sql.NullString + startedAt sql.NullString + finishedAt sql.NullString + exitCode sql.NullInt64 + stats sql.NullString + errMsg sql.NullString + createdAt string + ) + if err := rows.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, &groupID, + &j.ActorKind, &actorID, &startedAt, &finishedAt, + &exitCode, &stats, &errMsg, &createdAt); err != nil { + return nil, fmt.Errorf("store: scan job row: %w", err) + } + if schedID.Valid { + v := schedID.String + j.ScheduledID = &v + } + if groupID.Valid { + v := groupID.String + j.SourceGroupID = &v + } + if actorID.Valid { + v := actorID.String + j.ActorID = &v + } + if startedAt.Valid { + t, _ := time.Parse(time.RFC3339Nano, startedAt.String) + j.StartedAt = &t + } + if finishedAt.Valid { + t, _ := time.Parse(time.RFC3339Nano, finishedAt.String) + j.FinishedAt = &t + } + if exitCode.Valid { + i := int(exitCode.Int64) + j.ExitCode = &i + } + if stats.Valid && stats.String != "" { + j.Stats = json.RawMessage(stats.String) + } + if errMsg.Valid { + v := errMsg.String + j.Error = &v + } + t, _ := time.Parse(time.RFC3339Nano, createdAt) + j.CreatedAt = t + out = append(out, j) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate jobs by host: %w", err) + } + return out, nil +} + func nullableStr(s string) any { if s == "" { return nil diff --git a/internal/store/jobs_list_test.go b/internal/store/jobs_list_test.go new file mode 100644 index 0000000..9b2ee84 --- /dev/null +++ b/internal/store/jobs_list_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestListJobsByHost_OrderingAndLimit(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-jobs-1" + seedHost(t, s, hostID) + + // Create three jobs with explicit CreatedAt offsets. + base := time.Now().UTC().Truncate(time.Second) + for i, d := range []time.Duration{-3 * time.Hour, -1 * time.Hour, -2 * time.Hour} { + j := Job{ + ID: "j-" + string(rune('a'+i)) + "0000000000000000000000000", + HostID: hostID, + Kind: "backup", + ActorKind: "user", + CreatedAt: base.Add(d), + } + // Truncate ID to 26 chars (ULID width); the test only needs it + // to be unique and stable across rows. + j.ID = j.ID[:26] + if err := s.CreateJob(ctx, j); err != nil { + t.Fatalf("create job %d: %v", i, err) + } + } + + jobs, err := s.ListJobsByHost(ctx, hostID, 100) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(jobs) != 3 { + t.Fatalf("want 3 jobs, got %d", len(jobs)) + } + // Newest first ordering by created_at DESC. + for i := 0; i < len(jobs)-1; i++ { + if !jobs[i].CreatedAt.After(jobs[i+1].CreatedAt) && !jobs[i].CreatedAt.Equal(jobs[i+1].CreatedAt) { + t.Fatalf("ordering broken at %d: %v then %v", i, jobs[i].CreatedAt, jobs[i+1].CreatedAt) + } + } + + // Limit clamps results. + limited, err := s.ListJobsByHost(ctx, hostID, 2) + if err != nil { + t.Fatalf("list limit: %v", err) + } + if len(limited) != 2 { + t.Fatalf("limit 2: want 2 jobs, got %d", len(limited)) + } +} + +func TestListJobsByHost_OnlyThisHost(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const a, b = "h-jobs-a", "h-jobs-b" + seedHost(t, s, a) + seedHost(t, s, b) + + now := time.Now().UTC() + if err := s.CreateJob(ctx, Job{ID: "01HZZZZZZZZZZZZZZZZZZZZZ01", HostID: a, Kind: "backup", ActorKind: "user", CreatedAt: now}); err != nil { + t.Fatalf("create a: %v", err) + } + if err := s.CreateJob(ctx, Job{ID: "01HZZZZZZZZZZZZZZZZZZZZZ02", HostID: b, Kind: "backup", ActorKind: "user", CreatedAt: now}); err != nil { + t.Fatalf("create b: %v", err) + } + + jobs, err := s.ListJobsByHost(ctx, a, 100) + if err != nil { + t.Fatalf("list a: %v", err) + } + if len(jobs) != 1 || jobs[0].HostID != a { + t.Fatalf("expected 1 job for host a, got %d (%v)", len(jobs), jobs) + } +} diff --git a/internal/store/migrations/0023_host_repo_stats_history.sql b/internal/store/migrations/0023_host_repo_stats_history.sql new file mode 100644 index 0000000..e1f64d1 --- /dev/null +++ b/internal/store/migrations/0023_host_repo_stats_history.sql @@ -0,0 +1,19 @@ +-- 0023_host_repo_stats_history.sql +-- +-- Daily time-series of per-host repo metrics, used by the P6-03 +-- trend sparkline + chart. One row per (host_id, UTC date), +-- last-write-wins per column. Population is best-effort and +-- piggy-backs on the existing repo.stats WS message — nothing +-- else writes here. + +CREATE TABLE host_repo_stats_history ( + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + day TEXT NOT NULL, -- 'YYYY-MM-DD' UTC + total_size_bytes INTEGER, -- nullable: partial patches preserve existing value + snapshot_count INTEGER, -- nullable + recorded_at TEXT NOT NULL, -- RFC3339Nano of latest write + PRIMARY KEY (host_id, day) +); + +CREATE INDEX host_repo_stats_history_host_day + ON host_repo_stats_history(host_id, day DESC); diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go new file mode 100644 index 0000000..2bbf444 --- /dev/null +++ b/internal/web/sparkline/sparkline.go @@ -0,0 +1,355 @@ +// Package sparkline renders inline SVG sparklines and trend +// charts for the dashboard and host repo page. All output is +// pure server-rendered SVG with no JavaScript, no external +// stylesheet, and no client library. +package sparkline + +import ( + "fmt" + "html/template" + "math" + "strings" + "time" +) + +// RenderSparkline returns an inline SVG element of the +// given size containing a single polyline normalised across the +// full y-range of points. NaN entries break the polyline. With +// fewer than two real points the SVG still renders but contains +// only a faint baseline + an em-dash placeholder. +func RenderSparkline(points []float64, width, height int) template.HTML { + const pad = 2 + w := width + h := height + innerW := w - 2*pad + innerH := h - 2*pad + + var real []float64 + for _, p := range points { + if !math.IsNaN(p) { + real = append(real, p) + } + } + + var b strings.Builder + fmt.Fprintf(&b, + ``, + w, h) + + if len(real) < 2 { + fmt.Fprintf(&b, + ``, + pad, h/2, w-pad, h/2) + fmt.Fprintf(&b, + ``, + w/2, h/2+4, h-6) + b.WriteString(``) + return template.HTML(b.String()) + } + + min, max := real[0], real[0] + for _, v := range real { + if v < min { + min = v + } + if v > max { + max = v + } + } + span := max - min + if span == 0 { + span = 1 + } + + stepX := 0.0 + if len(points) > 1 { + stepX = float64(innerW) / float64(len(points)-1) + } + + var seg strings.Builder + flush := func() { + if seg.Len() == 0 { + return + } + fmt.Fprintf(&b, + ``, + strings.TrimSpace(seg.String())) + seg.Reset() + } + for i, v := range points { + if math.IsNaN(v) { + flush() + continue + } + x := float64(pad) + stepX*float64(i) + y := float64(pad) + float64(innerH) - (v-min)/span*float64(innerH) + fmt.Fprintf(&seg, "%.2f,%.2f ", x, y) + } + flush() + + cur := real[len(real)-1] + first := real[0] + delta := cur - first + sign := "+" + if delta < 0 { + sign = "-" + delta = -delta + } + fmt.Fprintf(&b, `current %.0f, %s%.0f over window`, cur, sign, delta) + + b.WriteString(``) + return template.HTML(b.String()) +} + +// Axis selects which y-axis a Series is normalised against. +type Axis int + +const ( + // AxisLeft maps the series to the left y-axis. + AxisLeft Axis = iota + // AxisRight maps the series to the right y-axis. + AxisRight +) + +// Format selects how a Series' values appear in hover tooltips. +type Format int + +const ( + // FormatBytes formats a value as a human-readable byte size. + FormatBytes Format = iota + // FormatCount formats a value as an integer count. + FormatCount +) + +// Series is one labelled trace on a chart. +type Series struct { + Name string + Points []float64 // NaN breaks the polyline + Stroke string // hex colour + Axis Axis + Format Format +} + +// ChartOpts controls rendering of the full trend chart. +type ChartOpts struct { + Width int + Height int + GridBands int // default 4 + EmptyLabel string // default "no data yet" +} + +// RenderChart returns an inline SVG with up to two y-axes, +// one polyline per series, hover-dot per data point, and X-axis +// labels at start / midpoint / end. With no series or empty +// series, renders a faint baseline + EmptyLabel centred. Points +// beyond len(days) are ignored. +func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML { + if opts.Width <= 0 { + opts.Width = 600 + } + if opts.Height <= 0 { + opts.Height = 220 + } + if opts.GridBands <= 0 { + opts.GridBands = 4 + } + if opts.EmptyLabel == "" { + opts.EmptyLabel = "no data yet" + } + const padL, padR, padT, padB = 72, 56, 16, 28 + w, h := opts.Width, opts.Height + innerW := w - padL - padR + innerH := h - padT - padB + + var b strings.Builder + fmt.Fprintf(&b, + ``, + w, h) + + hasData := false + for _, s := range series { + for _, p := range s.Points { + if !math.IsNaN(p) { + hasData = true + break + } + } + if hasData { + break + } + } + if !hasData || len(days) == 0 { + fmt.Fprintf(&b, + ``, + padL, h/2, w-padR, h/2) + fmt.Fprintf(&b, + `%s`, + w/2, h/2+4, opts.EmptyLabel) + b.WriteString(``) + return template.HTML(b.String()) + } + + for i := 0; i <= opts.GridBands; i++ { + y := padT + innerH*i/opts.GridBands + fmt.Fprintf(&b, + ``, + padL, y, w-padR, y) + } + + type axBounds struct { + min, max float64 + has bool + } + // Use fixed-order array to avoid map iteration non-determinism. + var axArr [2]axBounds + for _, s := range series { + a := &axArr[s.Axis] + for _, p := range s.Points { + if math.IsNaN(p) { + continue + } + if !a.has { + a.min, a.max, a.has = p, p, true + continue + } + if p < a.min { + a.min = p + } + if p > a.max { + a.max = p + } + } + } + for i := range axArr { + if axArr[i].has && axArr[i].max == axArr[i].min { + axArr[i].max = axArr[i].min + 1 + } + } + + stepX := 0.0 + if len(days) > 1 { + stepX = float64(innerW) / float64(len(days)-1) + } + + for _, s := range series { + a := &axArr[s.Axis] + if !a.has { + continue + } + var seg strings.Builder + flush := func() { + if seg.Len() == 0 { + return + } + fmt.Fprintf(&b, + ``, + s.Stroke, strings.TrimSpace(seg.String())) + seg.Reset() + } + for i, v := range s.Points { + if i >= len(days) { + break + } + if math.IsNaN(v) { + flush() + continue + } + x := float64(padL) + stepX*float64(i) + if len(days) == 1 { + // Single-day: pin the lone dot to the chart centre so it + // sits under the centred date label. + x = float64(padL) + float64(innerW)/2 + } + y := float64(padT) + float64(innerH) - (v-a.min)/(a.max-a.min)*float64(innerH) + fmt.Fprintf(&seg, "%.2f,%.2f ", x, y) + d := days[i] + fmt.Fprintf(&b, + `%s · %s: %s`, + x, y, s.Stroke, d.Format("2006-01-02"), s.Name, formatValue(v, s.Format)) + } + flush() + } + + if axArr[AxisLeft].has { + writeAxisLabels(&b, padL-6, padT, innerH, axArr[AxisLeft].min, axArr[AxisLeft].max, FormatBytes, "end") + // Rotated axis title in the left margin. Position inset from + // the viewBox edge by ≈ font-size so the rotated glyph extents + // don't clip against the SVG boundary. + cy := padT + innerH/2 + fmt.Fprintf(&b, + `Size`, + cy, cy) + } + if axArr[AxisRight].has { + writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start") + cy := padT + innerH/2 + fmt.Fprintf(&b, + `Snapshots`, + w-14, cy, w-14, cy) + } + + // X-axis labels at start / mid / end. With 1-2 days the indices + // collapse onto each other — dedupe so we don't stack overlapping + // "Jan 2" labels at the same x coordinate. + type xLabel struct { + idx int + anchor string + } + var xLabels []xLabel + switch { + case len(days) == 1: + xLabels = []xLabel{{0, "middle"}} + case len(days) == 2: + xLabels = []xLabel{{0, "start"}, {1, "end"}} + default: + xLabels = []xLabel{{0, "start"}, {len(days) / 2, "middle"}, {len(days) - 1, "end"}} + } + for _, l := range xLabels { + x := float64(padL) + stepX*float64(l.idx) + // With a single point, anchor "middle" centres on padL — push to + // the chart's centre line so the lone label sits over the dot. + if len(days) == 1 { + x = float64(padL) + float64(innerW)/2 + } + fmt.Fprintf(&b, + `%s`, + x, h-padB+16, l.anchor, days[l.idx].Format("Jan 2")) + } + + b.WriteString(``) + return template.HTML(b.String()) +} + +func writeAxisLabels(b *strings.Builder, x, padT, innerH int, min, max float64, f Format, anchor string) { + const bands = 4 + for i := 0; i <= bands; i++ { + y := padT + innerH*i/bands + v := max - (max-min)*float64(i)/float64(bands) + fmt.Fprintf(b, + `%s`, + x, y+3, anchor, formatValue(v, f)) + } +} + +func formatValue(v float64, f Format) string { + switch f { + case FormatBytes: + return humanBytes(v) + default: + return fmt.Sprintf("%.0f", v) + } +} + +func humanBytes(v float64) string { + const k = 1024.0 + units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + i := 0 + for v >= k && i < len(units)-1 { + v /= k + i++ + } + if v >= 100 { + return fmt.Sprintf("%.0f %s", v, units[i]) + } + return fmt.Sprintf("%.1f %s", v, units[i]) +} diff --git a/internal/web/sparkline/sparkline_test.go b/internal/web/sparkline/sparkline_test.go new file mode 100644 index 0000000..9320d1f --- /dev/null +++ b/internal/web/sparkline/sparkline_test.go @@ -0,0 +1,74 @@ +package sparkline + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func loadGolden(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read golden %s: %v", name, err) + } + return strings.TrimRight(string(b), "\n") +} + +func TestSparkline_Empty(t *testing.T) { + got := strings.TrimRight(string(RenderSparkline(nil, 80, 20)), "\n") + want := loadGolden(t, "empty.svg") + if got != want { + t.Errorf("empty sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestSparkline_SinglePoint(t *testing.T) { + got := strings.TrimRight(string(RenderSparkline([]float64{100}, 80, 20)), "\n") + want := loadGolden(t, "single_point.svg") + if got != want { + t.Errorf("single_point sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestSparkline_ThreePoints(t *testing.T) { + got := strings.TrimRight(string(RenderSparkline([]float64{10, 30, 20}, 80, 20)), "\n") + want := loadGolden(t, "three_points.svg") + if got != want { + t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestChart_Empty(t *testing.T) { + got := strings.TrimRight(string(RenderChart(nil, nil, ChartOpts{Width: 600, Height: 220})), "\n") + want := loadGolden(t, "chart_empty.svg") + if got != want { + t.Errorf("empty chart mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestChart_TwoSeries(t *testing.T) { + days := []time.Time{ + time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), + } + series := []Series{ + { + Name: "size", Stroke: "#3b82f6", Axis: AxisLeft, Format: FormatBytes, + Points: []float64{1024, 2048, 4096, 8192}, + }, + { + Name: "snapshots", Stroke: "#f59e0b", Axis: AxisRight, Format: FormatCount, + Points: []float64{1, 2, 3, 4}, + }, + } + got := strings.TrimRight(string(RenderChart(series, days, ChartOpts{Width: 600, Height: 220})), "\n") + want := loadGolden(t, "chart_two_series.svg") + if got != want { + t.Errorf("two_series chart mismatch:\nwant:\n%s\ngot:\n%s", want, got) + } +} diff --git a/internal/web/sparkline/testdata/chart_empty.svg b/internal/web/sparkline/testdata/chart_empty.svg new file mode 100644 index 0000000..4ffd92b --- /dev/null +++ b/internal/web/sparkline/testdata/chart_empty.svg @@ -0,0 +1 @@ +no data yet diff --git a/internal/web/sparkline/testdata/chart_two_series.svg b/internal/web/sparkline/testdata/chart_two_series.svg new file mode 100644 index 0000000..0f6b564 --- /dev/null +++ b/internal/web/sparkline/testdata/chart_two_series.svg @@ -0,0 +1 @@ +2026-05-01 · size: 1.0 KiB2026-05-02 · size: 2.0 KiB2026-05-03 · size: 4.0 KiB2026-05-04 · size: 8.0 KiB2026-05-01 · snapshots: 12026-05-02 · snapshots: 22026-05-03 · snapshots: 32026-05-04 · snapshots: 48.0 KiB6.2 KiB4.5 KiB2.8 KiB1.0 KiBSize43221SnapshotsMay 1May 3May 4 diff --git a/internal/web/sparkline/testdata/empty.svg b/internal/web/sparkline/testdata/empty.svg new file mode 100644 index 0000000..5f35061 --- /dev/null +++ b/internal/web/sparkline/testdata/empty.svg @@ -0,0 +1 @@ + diff --git a/internal/web/sparkline/testdata/single_point.svg b/internal/web/sparkline/testdata/single_point.svg new file mode 100644 index 0000000..5f35061 --- /dev/null +++ b/internal/web/sparkline/testdata/single_point.svg @@ -0,0 +1 @@ + diff --git a/internal/web/sparkline/testdata/three_points.svg b/internal/web/sparkline/testdata/three_points.svg new file mode 100644 index 0000000..1bbad3c --- /dev/null +++ b/internal/web/sparkline/testdata/three_points.svg @@ -0,0 +1 @@ +current 20, +10 over window diff --git a/tasks.md b/tasks.md index 088179f..9aaf4e8 100644 --- a/tasks.md +++ b/tasks.md @@ -371,7 +371,25 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. > `v0.9.0-11-gccaccd8-dirty` → `v9.9.9-smoke` in <5s; `.old` preserved > on disk; chip and hero tile cleared on reconnect; audit row landed. > Screenshots in `_diag/p6-update-sweep/`. -- [ ] **P6-03** (M) Repo size trend graphs (sparkline on host card, full chart on repo page). _(Was P4-06.)_ +- [x] **P6-03** (M) Repo size trend graphs (sparkline on host card, full chart on repo page). _(Was P4-06.)_ + +> **As shipped (2026-05-07, branch `tidy-up-last-backup-projection`):** +> Spec `docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md`, +> plan `docs/superpowers/plans/2026-05-07-p6-03-repo-size-trend.md`. +> Migration 0023 introduces `host_repo_stats_history` (one row per +> host per UTC day, last-write-wins per column via COALESCE — a +> prune-only or check-only patch never nulls a backup-time size +> we already captured). WS handler in `internal/server/ws/handler.go` +> writes a history row alongside the existing `UpsertHostRepoStats` +> call; failure is best-effort, logged at WARN. New `internal/web/sparkline` +> package emits inline SVG (sparkline + two-axis chart with hover +> dots and bytes/count formatting); golden-file tests, deterministic +> output. Dashboard host row gains a 30d sparkline cell between +> Repo size and Snapshots; host repo page gains a Trend panel with +> server-rendered `30d | 90d | 1y` range pills (htmx outerHTML +> swap, helper `buildRepoTrendView` shared between page-load and +> fragment endpoint). No new dependencies, no client JS, no agent +> change. CI green; in-browser smoke walk-through pending operator. - [ ] **P6-04** (M) Prometheus `/metrics` endpoint: per-host gauges (last backup timestamp, last backup status, repo size, snapshot count, agent online), server gauges (active alerts, build info), job duration histograms; protected by bearer token or IP allow-list. _(Was P4-08.)_ - [ ] **P6-05** (S) Document Prometheus integration + sample Grafana dashboard JSON. _(Was P4-09.)_ diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 37ca83f..a838a57 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.btn-amber{background:var(--warn);border-color:var(--warn);color:oklch(.18 .01 80)}.btn-amber:hover{filter:brightness(1.08)}.btn-amber:disabled,.btn-amber[disabled]{cursor:not-allowed;opacity:.45;pointer-events:none}.update-chip{align-items:center;background:color-mix(in oklch,var(--warn),transparent 30%);border:1px solid color-mix(in oklch,var(--warn),transparent 50%);border-radius:3px;color:oklch(.18 .01 80);display:inline-flex;font-size:10px;font-weight:500;gap:4px;line-height:1.4;padding:1px 6px;white-space:nowrap}.hero-tile{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;display:flex;flex-direction:column;gap:4px;padding:14px 16px;text-decoration:none;transition:filter .12s ease,background .12s ease}.hero-tile:hover{filter:brightness(1.08)}.hero-tile .hero-num{color:var(--ink);font-family:JetBrains Mono,ui-monospace,monospace;font-size:22px;font-weight:500;letter-spacing:-.01em}.hero-tile .hero-label{color:var(--ink-mute);font-size:11.5px}.hero-tile--amber{background:color-mix(in oklch,var(--warn),transparent 88%);border-color:color-mix(in oklch,var(--warn),transparent 60%)}.hero-tile--amber .hero-num{color:oklch(.86 .13 80)}.hero-tile--amber .hero-label{color:oklch(.78 .08 80)}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .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-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink:hover{color:oklch(.96 .005 250)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.btn-amber{background:var(--warn);border-color:var(--warn);color:oklch(.18 .01 80)}.btn-amber:hover{filter:brightness(1.08)}.btn-amber:disabled,.btn-amber[disabled]{cursor:not-allowed;opacity:.45;pointer-events:none}.update-chip{align-items:center;background:color-mix(in oklch,var(--warn),transparent 30%);border:1px solid color-mix(in oklch,var(--warn),transparent 50%);border-radius:3px;color:oklch(.18 .01 80);display:inline-flex;font-size:10px;font-weight:500;gap:4px;line-height:1.4;padding:1px 6px;white-space:nowrap}.hero-tile{background:var(--panel);border:1px solid var(--line-soft);border-radius:7px;display:flex;flex-direction:column;gap:4px;padding:14px 16px;text-decoration:none;transition:filter .12s ease,background .12s ease}.hero-tile:hover{filter:brightness(1.08)}.hero-tile .hero-num{color:var(--ink);font-family:JetBrains Mono,ui-monospace,monospace;font-size:22px;font-weight:500;letter-spacing:-.01em}.hero-tile .hero-label{color:var(--ink-mute);font-size:11.5px}.hero-tile--amber{background:color-mix(in oklch,var(--warn),transparent 88%);border-color:color-mix(in oklch,var(--warn),transparent 60%)}.hero-tile--amber .hero-num{color:oklch(.86 .13 80)}.hero-tile--amber .hero-label{color:oklch(.78 .08 80)}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr 96px .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag.tag-active{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%);color:var(--accent)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header,.user-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover,.user-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph,.user-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.jobs-row{align-items:center;display:grid;font-size:12.5px;gap:14px;grid-template-columns:110px 110px 90px 1fr 1fr 28px;padding:9px 14px}.jobs-row.head{color:var(--ink-mid);font-size:11px;letter-spacing:.08em;padding-bottom:11px;padding-top:11px;text-transform:uppercase}.jobs-row.clickable{position:relative}.jobs-row.clickable .row-link{display:block;inset:0;position:absolute;z-index:0}.jobs-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.jobs-row.clickable>*{pointer-events:none;position:relative;z-index:1}.jobs-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.h-\[2px\]{height:2px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-\[420px\]{width:420px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink:hover{color:oklch(.96 .005 250)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} diff --git a/web/styles/input.css b/web/styles/input.css index fb27b08..dd1a861 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -219,7 +219,7 @@ /* ---------- host row (the dashboard's load-bearing component) ---------- */ .host-row { display: grid; align-items: center; - grid-template-columns: 24px 1.4fr 0.95fr 1.5fr 0.75fr 0.7fr 0.7fr 1.1fr 92px; + grid-template-columns: 24px 1.4fr 0.95fr 1.5fr 0.75fr 96px 0.7fr 0.7fr 1.1fr 92px; column-gap: 18px; padding: 11px 16px; font-size: 13px; border-left: 3px solid transparent; @@ -439,6 +439,31 @@ .schd-row.clickable > .row-link { pointer-events: auto; } .schd-row.clickable > .row-action { pointer-events: auto; } + /* ---------- jobs rows (Jobs tab) ---------- */ + .jobs-row { + display: grid; + grid-template-columns: 110px 110px 90px 1fr 1fr 28px; + gap: 14px; + align-items: center; + padding: 9px 14px; + font-size: 12.5px; + } + .jobs-row.head { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-mid); + padding-top: 11px; + padding-bottom: 11px; + } + .jobs-row.clickable { position: relative; } + .jobs-row.clickable .row-link { + position: absolute; inset: 0; display: block; z-index: 0; + } + .jobs-row.clickable:hover { background: var(--panel-hi); cursor: pointer; } + .jobs-row.clickable > * { position: relative; z-index: 1; pointer-events: none; } + .jobs-row.clickable > .row-link { pointer-events: auto; } + /* ---------- cron preset chips ---------- */ .preset-chip { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index e29dbc8..e95ff78 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -213,9 +213,10 @@
OS · arch{{if eq $f.Sort "os"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}
Last backup{{if eq $f.Sort "last_backup"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}
+
30d trend
-
Alerts
-
Tags
+
Alerts
+
Tags
diff --git a/web/templates/pages/host_jobs.html b/web/templates/pages/host_jobs.html new file mode 100644 index 0000000..641981b --- /dev/null +++ b/web/templates/pages/host_jobs.html @@ -0,0 +1,65 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{template "host_chrome" .}} +{{$page := .Page}} +{{$host := $page.Host}} +
+ +
+

+ Recent jobs for this host — backups, prunes, checks, restores, repo init/probe, agent updates. + Newest first, limited to the last 100. Click a row for the full log. +

+
+ + {{if eq (len $page.Jobs) 0}} +
+

No jobs yet.

+

+ Trigger a backup from the Sources tab, or wait for a schedule to fire — jobs appear here as soon as they're queued. +

+
+ {{else}} +
+
+
Kind
+
Status
+
Actor
+
Started
+
Duration
+
+
+ {{range $i, $j := $page.Jobs}} +
+ +
{{$j.Kind}}
+
+ {{if eq $j.Status "succeeded"}} + succeeded + {{else if eq $j.Status "failed"}} + failed + {{else if eq $j.Status "cancelled"}} + cancelled + {{else if eq $j.Status "running"}} + running + {{else}} + {{$j.Status}} + {{end}} +
+
{{$j.ActorKind}}
+
+ {{if $j.StartedAt}}{{relTime $j.StartedAt}}{{else}}queued{{end}} +
+
+ {{if and $j.StartedAt $j.FinishedAt}}{{durationHuman $j.StartedAt $j.FinishedAt}}{{else}}{{end}} +
+
+
+ {{end}} +
+ {{end}} + +
+{{end}} diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html index 9d9e755..ebfb7cf 100644 --- a/web/templates/pages/host_repo.html +++ b/web/templates/pages/host_repo.html @@ -245,6 +245,12 @@ + {{/* ---------- Trend ---------- */}} +

Trend

+
+ {{template "repo_size_chart" (dict "Page" $page.Trend)}} +
+ {{/* ---------- Host-default hooks ---------- */}}

Host-default hooks

diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html index 2f02f0b..0eba05b 100644 --- a/web/templates/partials/host_chrome.html +++ b/web/templates/partials/host_chrome.html @@ -176,8 +176,7 @@ Sources {{$page.SourceGroupCount}} Schedules {{$page.ScheduleCount}} Repo -
Jobs
-
Settings
+ Jobs {{end}} diff --git a/web/templates/partials/host_row.html b/web/templates/partials/host_row.html index 128d417..d005676 100644 --- a/web/templates/partials/host_row.html +++ b/web/templates/partials/host_row.html @@ -35,6 +35,7 @@ {{- end -}}
{{bytes $h.RepoSizeBytes}}
+
{{.RepoSparklineSVG}}
{{- if eq $h.SnapshotCount 0 -}} diff --git a/web/templates/partials/repo_size_chart.html b/web/templates/partials/repo_size_chart.html new file mode 100644 index 0000000..2995958 --- /dev/null +++ b/web/templates/partials/repo_size_chart.html @@ -0,0 +1,22 @@ +{{define "repo_size_chart"}} +{{$trend := .Page}} +
+
+ Range: + 30d + 90d + 1y +
+
{{$trend.ChartSVG}}
+
+ repo size + snapshot count +
+
+{{end}}