From da518de3e677399f10cc8a70f410fff3930ba87a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:39:44 +0100 Subject: [PATCH 01/15] store: migration 0023 host_repo_stats_history --- .../0023_host_repo_stats_history.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/store/migrations/0023_host_repo_stats_history.sql 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); From b9c7ec6ebf856ad5df637474bcc7e269b622b7ed Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:43:20 +0100 Subject: [PATCH 02/15] store: history table helpers (upsert/list, COALESCE preserves prior values) --- internal/store/host_repo_stats_history.go | 98 ++++++++++++++ .../store/host_repo_stats_history_test.go | 120 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 internal/store/host_repo_stats_history.go create mode 100644 internal/store/host_repo_stats_history_test.go 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)) + } +} From bb2a88be24db1b74f24b60ce02845b7e674a9969 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:46:26 +0100 Subject: [PATCH 03/15] ws: record daily repo stats history alongside current upsert --- internal/server/ws/handler.go | 4 +++ internal/server/ws/handler_test.go | 39 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) 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) + } +} From db88c5a7d12d223fce73ce0348d915b8c23c5f4b Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:50:23 +0100 Subject: [PATCH 04/15] web/sparkline: inline-SVG sparkline renderer (empty / single / multi) --- internal/web/sparkline/sparkline.go | 101 ++++++++++++++++++ internal/web/sparkline/sparkline_test.go | 41 +++++++ internal/web/sparkline/testdata/empty.svg | 1 + .../web/sparkline/testdata/single_point.svg | 1 + .../web/sparkline/testdata/three_points.svg | 1 + 5 files changed, 145 insertions(+) create mode 100644 internal/web/sparkline/sparkline.go create mode 100644 internal/web/sparkline/sparkline_test.go create mode 100644 internal/web/sparkline/testdata/empty.svg create mode 100644 internal/web/sparkline/testdata/single_point.svg create mode 100644 internal/web/sparkline/testdata/three_points.svg diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go new file mode 100644 index 0000000..6d6f0f8 --- /dev/null +++ b/internal/web/sparkline/sparkline.go @@ -0,0 +1,101 @@ +// 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" +) + +// 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()) +} diff --git a/internal/web/sparkline/sparkline_test.go b/internal/web/sparkline/sparkline_test.go new file mode 100644 index 0000000..f0af51b --- /dev/null +++ b/internal/web/sparkline/sparkline_test.go @@ -0,0 +1,41 @@ +package sparkline + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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) + } +} 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 From e7d25cd704876d447b46f8e6a26146f3b60b6ac9 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:55:31 +0100 Subject: [PATCH 05/15] web/sparkline: two-axis trend chart with hover dots --- internal/web/sparkline/sparkline.go | 215 ++++++++++++++++++ internal/web/sparkline/sparkline_test.go | 33 +++ .../web/sparkline/testdata/chart_empty.svg | 1 + .../sparkline/testdata/chart_two_series.svg | 1 + 4 files changed, 250 insertions(+) create mode 100644 internal/web/sparkline/testdata/chart_empty.svg create mode 100644 internal/web/sparkline/testdata/chart_two_series.svg diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go index 6d6f0f8..8c5f433 100644 --- a/internal/web/sparkline/sparkline.go +++ b/internal/web/sparkline/sparkline.go @@ -9,6 +9,7 @@ import ( "html/template" "math" "strings" + "time" ) // RenderSparkline returns an inline SVG element of the @@ -99,3 +100,217 @@ func RenderSparkline(points []float64, width, height int) template.HTML { 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. +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 = 56, 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 math.IsNaN(v) { + flush() + continue + } + x := float64(padL) + stepX*float64(i) + 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") + } + if axArr[AxisRight].has { + writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start") + } + + xLabels := []int{0, len(days) / 2, len(days) - 1} + anchors := []string{"start", "middle", "end"} + for i, idx := range xLabels { + x := float64(padL) + stepX*float64(idx) + fmt.Fprintf(&b, + `%s`, + x, h-padB+16, anchors[i], days[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 index f0af51b..9320d1f 100644 --- a/internal/web/sparkline/sparkline_test.go +++ b/internal/web/sparkline/sparkline_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func loadGolden(t *testing.T, name string) string { @@ -39,3 +40,35 @@ func TestSparkline_ThreePoints(t *testing.T) { 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..52f8d64 --- /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..f84855c --- /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 KiB43221May 1May 3May 4 From 6e8a1c5b4589cab6fcc92ca58ede6d36163c6ac9 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 18:58:33 +0100 Subject: [PATCH 06/15] web/sparkline: guard days[i] against shorter days slice in RenderChart --- internal/web/sparkline/sparkline.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go index 8c5f433..e57fb4b 100644 --- a/internal/web/sparkline/sparkline.go +++ b/internal/web/sparkline/sparkline.go @@ -141,7 +141,8 @@ type ChartOpts struct { // 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. +// 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 @@ -246,6 +247,9 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM seg.Reset() } for i, v := range s.Points { + if i >= len(days) { + break + } if math.IsNaN(v) { flush() continue From be4ac02ddd490c07445ce20f1e2c589d0b76e2b1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 19:02:35 +0100 Subject: [PATCH 07/15] ui: 30d repo-size sparkline on every dashboard host row --- .../http/ui_dashboard_sparkline_test.go | 83 +++++++++++++++++++ internal/server/http/ui_handlers.go | 21 +++++ web/styles/input.css | 2 +- web/templates/pages/dashboard.html | 1 + web/templates/partials/host_row.html | 1 + 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 internal/server/http/ui_dashboard_sparkline_test.go 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/web/styles/input.css b/web/styles/input.css index fb27b08..cc0b7c9 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; diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index e29dbc8..5a8587d 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -213,6 +213,7 @@ +
30d trend
Alerts
Tags
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 -}} From 98cc490ea8a8c73be8c6d656554e408275362134 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 19:10:59 +0100 Subject: [PATCH 08/15] ui: trend panel + range selector on host repo page --- internal/server/http/server.go | 1 + internal/server/http/ui_repo.go | 60 ++++++++++++++ internal/server/http/ui_repo_trend.go | 25 ++++++ internal/server/http/ui_repo_trend_test.go | 89 +++++++++++++++++++++ internal/server/ui/ui.go | 1 + web/templates/pages/host_repo.html | 6 ++ web/templates/partials/repo_size_chart.html | 22 +++++ 7 files changed, 204 insertions(+) create mode 100644 internal/server/http/ui_repo_trend.go create mode 100644 internal/server/http/ui_repo_trend_test.go create mode 100644 web/templates/partials/repo_size_chart.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 17ecc7a..9679a5c 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -196,6 +196,7 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) 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_repo.go b/internal/server/http/ui_repo.go index 461a1ab..4807a63 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: 600, 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..50bb8f7 --- /dev/null +++ b/internal/server/http/ui_repo_trend_test.go @@ -0,0 +1,89 @@ +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") + } +} 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/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/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}} From 28cc55711d2bc62054152f9ae3369b093b19940c Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 19:14:34 +0100 Subject: [PATCH 09/15] test: assert Trend panel renders on full repo page --- internal/server/http/ui_repo_trend_test.go | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/server/http/ui_repo_trend_test.go b/internal/server/http/ui_repo_trend_test.go index 50bb8f7..c33359e 100644 --- a/internal/server/http/ui_repo_trend_test.go +++ b/internal/server/http/ui_repo_trend_test.go @@ -87,3 +87,37 @@ func TestUIRepoTrend_InvalidRangeFallsBackTo30d(t *testing.T) { 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") + } +} From 001575ae9c92ea48b821cf4754c8be04b40beabb Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 19:20:05 +0100 Subject: [PATCH 10/15] tasks: P6-03 done, repo size trend graphs --- tasks.md | 20 +++++++++++++++++++- web/static/css/styles.css | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) 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..4ba32e4 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}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.h-\[2px\]{height:2px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-\[420px\]{width:420px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink:hover{color:oklch(.96 .005 250)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} From 6ef58a707e1c06d7f4993c1cca14857768e01af8 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 20:32:48 +0100 Subject: [PATCH 11/15] ws: synthesize job.finished from update watcher so browser stream wakes up --- cmd/server/main.go | 2 +- internal/server/ws/update_watch.go | 35 ++++++++++- internal/server/ws/update_watch_test.go | 77 +++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 6 deletions(-) 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/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") + } +} From 28c8b58f936bec78370908565b6bea45be2c8b40 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 22:49:10 +0100 Subject: [PATCH 12/15] ui: per-host Jobs sub-tab; drop unused Settings stub Adds /hosts/{id}/jobs page listing recent jobs for the host (newest first, capped at 100) with click-through to /jobs/{id}. Converts the Jobs placeholder
to a real nav link; removes the Settings stub entirely. Also registers durationHuman template func and a .jobs-row CSS grid to match the existing .schd-row idiom. --- internal/server/http/server.go | 1 + internal/server/http/ui_host_jobs.go | 47 +++++++++++++ internal/server/http/ui_host_jobs_test.go | 85 +++++++++++++++++++++++ internal/server/ui/funcs.go | 22 ++++++ internal/store/jobs.go | 81 +++++++++++++++++++++ internal/store/jobs_list_test.go | 83 ++++++++++++++++++++++ web/static/css/styles.css | 2 +- web/styles/input.css | 25 +++++++ web/templates/pages/host_jobs.html | 65 +++++++++++++++++ web/templates/partials/host_chrome.html | 3 +- 10 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 internal/server/http/ui_host_jobs.go create mode 100644 internal/server/http/ui_host_jobs_test.go create mode 100644 internal/store/jobs_list_test.go create mode 100644 web/templates/pages/host_jobs.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 9679a5c..c2d90c3 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -195,6 +195,7 @@ 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) 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/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/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/web/static/css/styles.css b/web/static/css/styles.css index 4ba32e4..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 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}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.h-\[2px\]{height:2px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-\[420px\]{width:420px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink:hover{color:oklch(.96 .005 250)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-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 cc0b7c9..dd1a861 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -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/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/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}} From 06fd440dd4f27b630a277949661e3394397df0ea Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 22:55:12 +0100 Subject: [PATCH 13/15] =?UTF-8?q?ui:=20chart=20polish=20=E2=80=94=20rotate?= =?UTF-8?q?d=20y-axis=20labels,=20wider=20viewBox,=20single-day=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rotated 'Size' (left) and 'Snapshots' (right) axis titles in the chart's outer margins so the two y-axes are self-describing. - Bump the chart viewBox from 600x220 to 640x220 and lift padL from 56 to 72 so the rotated labels and byte tick numbers don't crowd. - Dedupe the X-axis labels for short windows (1 or 2 days collapsed the start/mid/end indices onto each other, stacking 'May 7' three times); the 1-day case now centres a single label, 2-day uses start+end only. - Pin a lone data dot to the chart centre instead of the left edge when len(days)==1, so it sits under the centred date label. Goldens regenerated. --- internal/server/http/ui_repo.go | 2 +- internal/web/sparkline/sparkline.go | 47 ++++++++++++++++--- .../web/sparkline/testdata/chart_empty.svg | 2 +- .../sparkline/testdata/chart_two_series.svg | 2 +- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index 4807a63..a630831 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -284,7 +284,7 @@ func (s *Server) buildRepoTrendView(ctx context.Context, hostID, rangeKey string 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: 600, Height: 220}) + }, dayList, sparkline.ChartOpts{Width: 640, Height: 220}) return repoTrendView{HostID: hostID, Range: rangeKey, ChartSVG: chartSVG} } diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go index e57fb4b..2bbf444 100644 --- a/internal/web/sparkline/sparkline.go +++ b/internal/web/sparkline/sparkline.go @@ -156,7 +156,7 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM if opts.EmptyLabel == "" { opts.EmptyLabel = "no data yet" } - const padL, padR, padT, padB = 56, 56, 16, 28 + const padL, padR, padT, padB = 72, 56, 16, 28 w, h := opts.Width, opts.Height innerW := w - padL - padR innerH := h - padT - padB @@ -255,6 +255,11 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM 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] @@ -267,18 +272,48 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM 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) } - xLabels := []int{0, len(days) / 2, len(days) - 1} - anchors := []string{"start", "middle", "end"} - for i, idx := range xLabels { - x := float64(padL) + stepX*float64(idx) + // 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, anchors[i], days[idx].Format("Jan 2")) + x, h-padB+16, l.anchor, days[l.idx].Format("Jan 2")) } b.WriteString(``) diff --git a/internal/web/sparkline/testdata/chart_empty.svg b/internal/web/sparkline/testdata/chart_empty.svg index 52f8d64..4ffd92b 100644 --- a/internal/web/sparkline/testdata/chart_empty.svg +++ b/internal/web/sparkline/testdata/chart_empty.svg @@ -1 +1 @@ -no data yet +no data yet diff --git a/internal/web/sparkline/testdata/chart_two_series.svg b/internal/web/sparkline/testdata/chart_two_series.svg index f84855c..0f6b564 100644 --- a/internal/web/sparkline/testdata/chart_two_series.svg +++ b/internal/web/sparkline/testdata/chart_two_series.svg @@ -1 +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 KiB43221May 1May 3May 4 +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 From 51192c3603eb9f21a769ccdc19364f854aa23a07 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 22:55:21 +0100 Subject: [PATCH 14/15] =?UTF-8?q?ui+store:=20dashboard=20polish=20?= =?UTF-8?q?=E2=80=94=20repo=20size=20projection=20+=20header=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project total_size_bytes onto hosts.repo_size_bytes inside the UpsertHostRepoStats transaction. The hosts row column has been unwritten since the initial schema in 0001, so the dashboard's Repo size cell has always rendered '—' even after backups. Now the column updates atomically alongside the host_repo_stats row, and FleetSummary's SUM(repo_size_bytes) becomes accurate too. - Right-align the Alerts column header so it sits over its right-aligned value (was floating left of column, ambiguous). - Add text-ink-mid to the 30d trend / Alerts / Tags headers so all column headers share the same brightness. --- internal/store/host_repo_stats.go | 15 +++++++++++++++ web/templates/pages/dashboard.html | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) 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/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index 5a8587d..e95ff78 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -213,10 +213,10 @@ -
30d trend
+
30d trend
-
Alerts
-
Tags
+
Alerts
+
Tags
From a28bda2031c2bdafa4120c0cd57ee1dee68fd7d7 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 22:55:36 +0100 Subject: [PATCH 15/15] smoke env: systemd --user unit + Make targets so the dev server outlives shell tool boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spent half an evening fighting a smoke server that kept getting SIGTERM'd mid-iteration. Root cause: backgrounded processes spawned from sandboxed shell tool calls don't outlive the parent — even with nohup + disown. Fix: hand the server to user-systemd as a transient unit so its lifecycle is owned by the user's session, not by whichever bash subprocess started it. New Make targets: make smoke-restart build server + (re)launch as systemd --user unit make smoke-status show unit status make smoke-logs tail $HOME/smoke/server.log make smoke-stop stop the unit make smoke-deploy full rebuild + restage agent assets + restart Documents the workflow in CLAUDE.md so the next session doesn't relitigate. --- CLAUDE.md | 25 +++++++++++++++++++++++-- Makefile | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) 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 \