P6-03 repo size trend + agent-update UI fix + dashboard polish #21

Merged
steve merged 17 commits from tidy-up-last-backup-projection into main 2026-05-07 23:00:04 +01:00
7 changed files with 204 additions and 0 deletions
Showing only changes of commit 8be551349c - Show all commits
+1
View File
@@ -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)
+60
View File
@@ -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 {
+25
View File
@@ -0,0 +1,25 @@
// ui_repo_trend.go — htmx fragment endpoint for the repo-page
// trend chart. Returns just the chart partial wrapped in
// <div id="repo-trend-chart"> 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)
}
}
@@ -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")
}
}
+1
View File
@@ -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")
+6
View File
@@ -245,6 +245,12 @@
</div>
</div>
{{/* ---------- Trend ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Trend</h2>
<div class="panel rounded-[7px] p-5">
{{template "repo_size_chart" (dict "Page" $page.Trend)}}
</div>
{{/* ---------- Host-default hooks ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-9 mb-3.5">Host-default hooks</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/hooks" class="panel rounded-[7px] p-5">
@@ -0,0 +1,22 @@
{{define "repo_size_chart"}}
{{$trend := .Page}}
<div id="repo-trend-chart" data-range="{{$trend.Range}}">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-ink-mid">Range:</span>
<a class="btn btn-ghost-xs {{if eq "30d" $trend.Range}}is-active{{end}}"
hx-get="/hosts/{{$trend.HostID}}/repo/trend?range=30d"
hx-target="#repo-trend-chart" hx-swap="outerHTML">30d</a>
<a class="btn btn-ghost-xs {{if eq "90d" $trend.Range}}is-active{{end}}"
hx-get="/hosts/{{$trend.HostID}}/repo/trend?range=90d"
hx-target="#repo-trend-chart" hx-swap="outerHTML">90d</a>
<a class="btn btn-ghost-xs {{if eq "1y" $trend.Range}}is-active{{end}}"
hx-get="/hosts/{{$trend.HostID}}/repo/trend?range=1y"
hx-target="#repo-trend-chart" hx-swap="outerHTML">1y</a>
</div>
<div class="text-ink">{{$trend.ChartSVG}}</div>
<div class="flex gap-4 mt-2 text-xs text-ink-mid">
<span><span class="inline-block w-3 h-[2px] align-middle" style="background:#3b82f6"></span> repo size</span>
<span><span class="inline-block w-3 h-[2px] align-middle" style="background:#f59e0b"></span> snapshot count</span>
</div>
</div>
{{end}}