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 @@
OS · arch{{if eq $f.Sort "os"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}
Last backup{{if eq $f.Sort "last_backup"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}
Repo size{{if eq $f.Sort "repo_size"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}
+
30d trend
Snapshots{{if eq $f.Sort "snapshot_count"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}
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 -}}