From 8be551349c77940e7c522f56807a378ad12c7cc8 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 19:10:59 +0100 Subject: [PATCH] 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}}