1557 lines
45 KiB
Markdown
1557 lines
45 KiB
Markdown
# P6-03 Repo size trend — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Per-host repo growth trend — sparkline on every dashboard
|
|
row plus a `30d|90d|1y` chart with snapshot-count overlay on the
|
|
host repo page.
|
|
|
|
**Architecture:** New `host_repo_stats_history` table holds one
|
|
row per `(host_id, UTC date)`, written from the existing
|
|
`MsgRepoStats` WebSocket handler alongside the current-stats
|
|
upsert. A pure-Go `internal/web/sparkline` package emits inline
|
|
SVG; the dashboard handler attaches a per-row sparkline as
|
|
`template.HTML` and the repo page renders the chart in a new
|
|
"Trend" panel. Range switching is an htmx server fragment.
|
|
|
|
**Tech Stack:** Go (`net/http` + chi), SQLite (modernc), Go
|
|
`html/template`, HTMX, server-rendered inline SVG, no client
|
|
library.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-05-07-p6-03-repo-size-trend-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Create:**
|
|
- `internal/store/migrations/0023_host_repo_stats_history.sql` — schema
|
|
- `internal/store/host_repo_stats_history.go` — store helpers
|
|
- `internal/store/host_repo_stats_history_test.go` — store tests
|
|
- `internal/web/sparkline/sparkline.go` — SVG renderer
|
|
- `internal/web/sparkline/sparkline_test.go` — golden tests
|
|
- `internal/web/sparkline/testdata/*.svg` — golden files
|
|
- `web/templates/partials/repo_size_sparkline.html` — dashboard cell
|
|
- `web/templates/partials/repo_size_chart.html` — trend panel inner
|
|
- `internal/server/http/ui_repo_trend.go` — `GET /hosts/{id}/repo/trend`
|
|
|
|
**Modify:**
|
|
- `internal/server/ws/handler.go` — write to history table after
|
|
the existing upsert (around line 337)
|
|
- `internal/server/http/ui_handlers.go` — attach per-row
|
|
`RepoSparklineSVG template.HTML` to `dashboardHostRow`
|
|
- `internal/server/http/ui_repo.go` — load 30d series + initial
|
|
chart SVG into `hostRepoPage`
|
|
- `internal/server/http/server.go` — mount the new trend route
|
|
- `web/templates/partials/host_row.html` — sparkline cell
|
|
- `web/templates/pages/host_repo.html` — trend panel
|
|
- `web/templates/pages/dashboard.html` — column header (if hosts
|
|
table is grid-based)
|
|
- `tasks.md` — flip P6-03 to `[x]` with as-shipped block
|
|
|
|
---
|
|
|
|
## Task 1: Migration — `host_repo_stats_history` table
|
|
|
|
**Files:**
|
|
- Create: `internal/store/migrations/0023_host_repo_stats_history.sql`
|
|
- Test: `internal/store/migrate_test.go` (existing — runs every migration end-to-end)
|
|
|
|
- [ ] **Step 1: Write the migration**
|
|
|
|
Create `internal/store/migrations/0023_host_repo_stats_history.sql`:
|
|
|
|
```sql
|
|
-- 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);
|
|
```
|
|
|
|
- [ ] **Step 2: Run the existing migration test**
|
|
|
|
Run: `go test ./internal/store -run TestMigrate -v`
|
|
Expected: PASS — the migration runner walks every `.sql` in
|
|
`migrations/` in order; a syntactically valid file is enough at
|
|
this stage.
|
|
|
|
- [ ] **Step 3: Run vet**
|
|
|
|
Run: `go vet ./...`
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/store/migrations/0023_host_repo_stats_history.sql
|
|
git commit -m "store: migration 0023 host_repo_stats_history"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Store helpers — Upsert + List
|
|
|
|
**Files:**
|
|
- Create: `internal/store/host_repo_stats_history.go`
|
|
- Test: `internal/store/host_repo_stats_history_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `internal/store/host_repo_stats_history_test.go`:
|
|
|
|
```go
|
|
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))
|
|
}
|
|
// Ordering ascending by day.
|
|
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))
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/store -run TestHostRepoStatsHistory -v`
|
|
Expected: FAIL — `UpsertHostRepoStatsHistory` and
|
|
`ListHostRepoStatsHistory` undefined.
|
|
|
|
- [ ] **Step 3: Implement the store helpers**
|
|
|
|
Create `internal/store/host_repo_stats_history.go`:
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/store -run TestHostRepoStatsHistory -v`
|
|
Expected: PASS for all three subtests.
|
|
|
|
- [ ] **Step 5: Run full store package + vet**
|
|
|
|
Run: `go test ./internal/store/... && go vet ./...`
|
|
Expected: PASS, no vet output.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/store/host_repo_stats_history.go \
|
|
internal/store/host_repo_stats_history_test.go
|
|
git commit -m "store: history table helpers (upsert/list, COALESCE preserves prior values)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: WS handler writes history alongside current stats
|
|
|
|
**Files:**
|
|
- Modify: `internal/server/ws/handler.go:337` (after the existing
|
|
`UpsertHostRepoStats` call)
|
|
- Test: `internal/server/ws/handler_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `internal/server/ws/handler_test.go` (use the existing
|
|
test harness in that file — `runHandler`, `seededDeps`, etc.;
|
|
read the file before adding to confirm helper names):
|
|
|
|
```go
|
|
func TestHandlerRepoStats_WritesHistoryRow(t *testing.T) {
|
|
t.Parallel()
|
|
deps, hostID := seededDeps(t)
|
|
ctx := context.Background()
|
|
|
|
total := int64(12345)
|
|
count := int64(7)
|
|
env := api.Envelope{
|
|
Type: api.MsgRepoStats,
|
|
HostID: hostID,
|
|
AgentTS: time.Now().UTC(),
|
|
}
|
|
payload := api.RepoStatsPayload{
|
|
TotalSizeBytes: &total,
|
|
SnapshotCount: &count,
|
|
}
|
|
envWithPayload := mustEnvWithPayload(t, env, payload) // existing helper
|
|
|
|
if err := dispatchEnvelope(ctx, deps, hostID, envWithPayload); err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
|
|
day := time.Now().UTC().Format("2006-01-02")
|
|
pts, err := deps.Store.ListHostRepoStatsHistory(ctx, hostID, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(pts) != 1 {
|
|
t.Fatalf("want 1 history row, got %d", len(pts))
|
|
}
|
|
if got := pts[0].Day.Format("2006-01-02"); got != day {
|
|
t.Errorf("day: want %s, got %s", day, 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)
|
|
}
|
|
}
|
|
```
|
|
|
|
If `dispatchEnvelope` / `seededDeps` / `mustEnvWithPayload` are
|
|
named differently in the existing test file, adopt the existing
|
|
naming — the assertions above are what matters.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/server/ws -run TestHandlerRepoStats_WritesHistoryRow -v`
|
|
Expected: FAIL — `len(pts) == 0`.
|
|
|
|
- [ ] **Step 3: Wire the history write**
|
|
|
|
In `internal/server/ws/handler.go`, in the `case api.MsgRepoStats:`
|
|
block (around line 319), after the existing `UpsertHostRepoStats`
|
|
call (line 337), add:
|
|
|
|
```go
|
|
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)
|
|
}
|
|
```
|
|
|
|
Place it after the existing `else { slog.Info(...) }` block — a
|
|
history-write failure must not block the main upsert log line.
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/server/ws -run TestHandlerRepoStats -v`
|
|
Expected: PASS, including any pre-existing repo-stats tests.
|
|
|
|
- [ ] **Step 5: Run vet**
|
|
|
|
Run: `go vet ./...`
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/server/ws/handler.go internal/server/ws/handler_test.go
|
|
git commit -m "ws: record daily repo stats history alongside current upsert"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Sparkline package — empty + single + multi-point
|
|
|
|
**Files:**
|
|
- Create: `internal/web/sparkline/sparkline.go`
|
|
- Create: `internal/web/sparkline/sparkline_test.go`
|
|
- Create: `internal/web/sparkline/testdata/empty.svg`
|
|
- Create: `internal/web/sparkline/testdata/single_point.svg`
|
|
- Create: `internal/web/sparkline/testdata/three_points.svg`
|
|
|
|
This task introduces the renderer with one of three behaviours
|
|
exercised. The chart-rendering function is added in Task 5.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `internal/web/sparkline/sparkline_test.go`:
|
|
|
|
```go
|
|
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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/web/sparkline -v`
|
|
Expected: FAIL — package does not exist yet.
|
|
|
|
- [ ] **Step 3: Implement the sparkline renderer**
|
|
|
|
Create `internal/web/sparkline/sparkline.go`:
|
|
|
|
```go
|
|
// Package sparkline renders inline SVG sparklines and trend
|
|
// charts for the dashboard and host repo page. All output is
|
|
// pure server-rendered SVG with no JavaScript, no external
|
|
// stylesheet, and no client library.
|
|
package sparkline
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RenderSparkline returns an inline SVG <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,
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-sparkline" role="img" aria-label="repo size trend">`,
|
|
w, h)
|
|
|
|
if len(real) < 2 {
|
|
fmt.Fprintf(&b,
|
|
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/>`,
|
|
pad, h/2, w-pad, h/2)
|
|
fmt.Fprintf(&b,
|
|
`<text x="%d" y="%d" text-anchor="middle" font-size="%d" fill="currentColor" fill-opacity="0.4">—</text>`,
|
|
w/2, h/2+4, h-6)
|
|
b.WriteString(`</svg>`)
|
|
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 // avoid divide-by-zero; line will be flat at mid-height
|
|
}
|
|
|
|
// Build the polyline string. Skip NaN entries by ending the
|
|
// current segment and starting a new <polyline>.
|
|
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,
|
|
`<polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
|
|
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()
|
|
|
|
// Title element for native hover (current → first delta).
|
|
cur := real[len(real)-1]
|
|
first := real[0]
|
|
delta := cur - first
|
|
sign := "+"
|
|
if delta < 0 {
|
|
sign = "-"
|
|
delta = -delta
|
|
}
|
|
fmt.Fprintf(&b, `<title>current %.0f, %s%.0f over window</title>`, cur, sign, delta)
|
|
|
|
b.WriteString(`</svg>`)
|
|
return template.HTML(b.String())
|
|
}
|
|
|
|
// Series and ChartOpts are added in Task 5. They live in the same
|
|
// package so the chart renderer can share the normalisation
|
|
// helpers.
|
|
var _ = time.Time{} // placeholder import; removed in Task 5
|
|
```
|
|
|
|
- [ ] **Step 4: Run test once with `-update` first to capture goldens**
|
|
|
|
Capture the actual output as goldens. Easiest: run a small
|
|
program once, or have the test write goldens when missing — but
|
|
given how tight this skill expects steps to be, just run the
|
|
tests, observe the diffs, and create the goldens by hand from
|
|
the failing-test output.
|
|
|
|
Run: `go test ./internal/web/sparkline -v`
|
|
Expected: FAIL — three goldens missing.
|
|
|
|
Read the failure output for each test (`got:` block) and write
|
|
those exact strings to:
|
|
- `internal/web/sparkline/testdata/empty.svg`
|
|
- `internal/web/sparkline/testdata/single_point.svg`
|
|
- `internal/web/sparkline/testdata/three_points.svg`
|
|
|
|
End each file with no trailing newline (or trim in test, which we
|
|
already do).
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/web/sparkline -v`
|
|
Expected: PASS for all three subtests.
|
|
|
|
- [ ] **Step 6: Run vet**
|
|
|
|
Run: `go vet ./...`
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add internal/web/sparkline/
|
|
git commit -m "web/sparkline: inline-SVG sparkline renderer (empty / single / multi)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Chart renderer — two-axis line chart with hover dots
|
|
|
|
**Files:**
|
|
- Modify: `internal/web/sparkline/sparkline.go` — add `RenderChart`
|
|
- Modify: `internal/web/sparkline/sparkline_test.go` — golden tests
|
|
- Create: `internal/web/sparkline/testdata/chart_empty.svg`
|
|
- Create: `internal/web/sparkline/testdata/chart_two_series.svg`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `internal/web/sparkline/sparkline_test.go`:
|
|
|
|
```go
|
|
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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/web/sparkline -run TestChart -v`
|
|
Expected: FAIL — `RenderChart`, `Series`, `ChartOpts`,
|
|
`AxisLeft`, `AxisRight`, `FormatBytes`, `FormatCount` undefined.
|
|
|
|
- [ ] **Step 3: Implement the chart renderer**
|
|
|
|
Replace the placeholder import block in
|
|
`internal/web/sparkline/sparkline.go` with these declarations and
|
|
the `RenderChart` function (keep `RenderSparkline` as-is):
|
|
|
|
```go
|
|
// Axis selects which y-axis a Series is normalised against.
|
|
type Axis int
|
|
|
|
const (
|
|
AxisLeft Axis = iota
|
|
AxisRight
|
|
)
|
|
|
|
// Format selects how a Series' values appear in hover tooltips.
|
|
type Format int
|
|
|
|
const (
|
|
FormatBytes Format = iota
|
|
FormatCount
|
|
)
|
|
|
|
// Series is one labelled trace on a chart.
|
|
type Series struct {
|
|
Name string
|
|
Points []float64 // NaN breaks the polyline
|
|
Stroke string // hex color
|
|
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 <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,
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time">`,
|
|
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,
|
|
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/>`,
|
|
padL, h/2, w-padR, h/2)
|
|
fmt.Fprintf(&b,
|
|
`<text x="%d" y="%d" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">%s</text>`,
|
|
w/2, h/2+4, opts.EmptyLabel)
|
|
b.WriteString(`</svg>`)
|
|
return template.HTML(b.String())
|
|
}
|
|
|
|
// Gridlines.
|
|
for i := 0; i <= opts.GridBands; i++ {
|
|
y := padT + innerH*i/opts.GridBands
|
|
fmt.Fprintf(&b,
|
|
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.08"/>`,
|
|
padL, y, w-padR, y)
|
|
}
|
|
|
|
// Per-axis min/max.
|
|
type ax struct{ min, max float64; has bool }
|
|
axes := map[Axis]*ax{AxisLeft: {}, AxisRight: {}}
|
|
for _, s := range series {
|
|
a := axes[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 _, a := range axes {
|
|
if a.has && a.max == a.min {
|
|
a.max = a.min + 1
|
|
}
|
|
}
|
|
|
|
stepX := 0.0
|
|
if len(days) > 1 {
|
|
stepX = float64(innerW) / float64(len(days)-1)
|
|
}
|
|
|
|
// Polylines + hover dots.
|
|
for _, s := range series {
|
|
a := axes[s.Axis]
|
|
if !a.has {
|
|
continue
|
|
}
|
|
var seg strings.Builder
|
|
flush := func() {
|
|
if seg.Len() == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintf(&b,
|
|
`<polyline fill="none" stroke="%s" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
|
|
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,
|
|
`<circle cx="%.2f" cy="%.2f" r="2.5" fill="%s"><title>%s · %s: %s</title></circle>`,
|
|
x, y, s.Stroke, d.Format("2006-01-02"), s.Name, formatValue(v, s.Format))
|
|
}
|
|
flush()
|
|
}
|
|
|
|
// Y-axis labels.
|
|
if axes[AxisLeft].has {
|
|
writeAxisLabels(&b, padL-6, padT, innerH, axes[AxisLeft].min, axes[AxisLeft].max, FormatBytes, "end")
|
|
}
|
|
if axes[AxisRight].has {
|
|
writeAxisLabels(&b, w-padR+6, padT, innerH, axes[AxisRight].min, axes[AxisRight].max, FormatCount, "start")
|
|
}
|
|
|
|
// X-axis labels (start / mid / end).
|
|
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,
|
|
`<text x="%.2f" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
|
|
x, h-padB+16, anchors[i], days[idx].Format("Jan 2"))
|
|
}
|
|
|
|
b.WriteString(`</svg>`)
|
|
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,
|
|
`<text x="%d" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
|
|
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])
|
|
}
|
|
```
|
|
|
|
Remove the `var _ = time.Time{}` placeholder line.
|
|
|
|
- [ ] **Step 4: Run tests to capture goldens**
|
|
|
|
Run: `go test ./internal/web/sparkline -run TestChart -v`
|
|
Expected: FAIL — goldens missing. Copy the `got:` blocks into:
|
|
- `internal/web/sparkline/testdata/chart_empty.svg`
|
|
- `internal/web/sparkline/testdata/chart_two_series.svg`
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/web/sparkline -v`
|
|
Expected: PASS for all sparkline + chart tests.
|
|
|
|
- [ ] **Step 6: Run vet**
|
|
|
|
Run: `go vet ./...`
|
|
Expected: no output.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add internal/web/sparkline/
|
|
git commit -m "web/sparkline: two-axis trend chart with hover dots"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Dashboard host_row sparkline cell
|
|
|
|
**Files:**
|
|
- Modify: `internal/server/http/ui_handlers.go` — attach `RepoSparklineSVG`
|
|
- Modify: `web/templates/partials/host_row.html` — render the cell
|
|
- Modify: `web/templates/pages/dashboard.html` — add column header (if header row is grid-defined)
|
|
- Test: `internal/server/http/ui_dashboard_test.go` (existing — extend; if absent, create)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Open `internal/server/http/ui_dashboard_test.go` (or create with
|
|
the standard test scaffolding used elsewhere — `httptest.NewServer`
|
|
+ a logged-in cookie helper; copy the pattern from
|
|
`ui_repo_test.go`). Add:
|
|
|
|
```go
|
|
func TestDashboard_HostRowSparklineRendersWithHistory(t *testing.T) {
|
|
t.Parallel()
|
|
srv := newTestServer(t) // existing helper
|
|
hostID := srv.seedHost(t, "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 := srv.deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
|
store.HostRepoStats{TotalSizeBytes: &v}, time.Now().UTC()); err != nil {
|
|
t.Fatalf("upsert %s: %v", day, err)
|
|
}
|
|
}
|
|
|
|
body := srv.getHTML(t, "/") // existing helper returns string
|
|
if !strings.Contains(body, `class="repo-sparkline"`) {
|
|
t.Errorf("expected sparkline SVG in dashboard body, missing")
|
|
}
|
|
if !strings.Contains(body, `<polyline`) {
|
|
t.Errorf("expected polyline element in dashboard body")
|
|
}
|
|
}
|
|
|
|
func TestDashboard_HostRowSparklineEmptyState(t *testing.T) {
|
|
t.Parallel()
|
|
srv := newTestServer(t)
|
|
srv.seedHost(t, "h-empty")
|
|
|
|
body := srv.getHTML(t, "/")
|
|
if !strings.Contains(body, `class="repo-sparkline"`) {
|
|
t.Errorf("expected sparkline SVG element")
|
|
}
|
|
if !strings.Contains(body, `>—<`) {
|
|
t.Errorf("expected em-dash placeholder for empty history")
|
|
}
|
|
}
|
|
```
|
|
|
|
If the helper names differ, mirror what's already used by the
|
|
existing dashboard / repo tests. Inspect `dashboard_filter_test.go`
|
|
or `ui_repo_test.go` for the canonical setup.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/server/http -run TestDashboard_HostRowSparkline -v`
|
|
Expected: FAIL — no `repo-sparkline` class in output.
|
|
|
|
- [ ] **Step 3: Attach sparkline to dashboard rows**
|
|
|
|
In `internal/server/http/ui_handlers.go`, extend `dashboardHostRow`:
|
|
|
|
```go
|
|
type dashboardHostRow struct {
|
|
Host store.Host
|
|
RunAllScheduleID string
|
|
NextRun *time.Time
|
|
UpdateAvailable bool
|
|
TargetVersion string
|
|
// RepoSparklineSVG is a 30-day total_size_bytes sparkline,
|
|
// pre-rendered server-side. Empty when no history rows exist
|
|
// for the host (rendered as the "—" placeholder).
|
|
RepoSparklineSVG template.HTML
|
|
}
|
|
```
|
|
|
|
Add the `html/template` import if not present (it likely is —
|
|
verify before adding).
|
|
|
|
In `handleUIDashboard`, inside the per-host loop after computing
|
|
`row`, fetch and render the sparkline. Add the import for the
|
|
sparkline package (`"gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline"`):
|
|
|
|
```go
|
|
since := time.Now().UTC().AddDate(0, 0, -30)
|
|
pts, perr := s.deps.Store.ListHostRepoStatsHistory(r.Context(), h.ID, since)
|
|
if perr != nil {
|
|
slog.Warn("ui dashboard: list repo history", "host_id", h.ID, "err", perr)
|
|
}
|
|
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)
|
|
```
|
|
|
|
Add `"math"` to the imports if missing.
|
|
|
|
- [ ] **Step 4: Render the cell in the partial**
|
|
|
|
Modify `web/templates/partials/host_row.html`. Insert a new cell
|
|
between the existing "Repo size" cell (line 37) and the
|
|
"Snapshots" cell (line 38):
|
|
|
|
```html
|
|
<div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes $h.RepoSizeBytes}}</div>
|
|
<div class="repo-sparkline-cell {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">{{.RepoSparklineSVG}}</div>
|
|
<div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
|
|
```
|
|
|
|
- [ ] **Step 5: Update the dashboard column header**
|
|
|
|
Open `web/templates/pages/dashboard.html`. Find the table's
|
|
column-header row (search for the cell labelled "Repo size" or
|
|
"Snapshots"). Add a new header cell with text "30d trend"
|
|
between them, and update the grid-template-columns rule (or
|
|
whatever column layout is used) so the sparkline cell has a fixed
|
|
~96px width. Keep the change minimal — match the existing
|
|
class-utility style.
|
|
|
|
If the dashboard uses CSS grid like `grid-template-columns`
|
|
defined in a `<style>` block, add an extra column with width
|
|
`96px`.
|
|
|
|
- [ ] **Step 6: Run tests to verify they pass**
|
|
|
|
Run: `go test ./internal/server/http -run TestDashboard_HostRowSparkline -v`
|
|
Expected: PASS for both subtests.
|
|
|
|
- [ ] **Step 7: Run full http package + vet**
|
|
|
|
Run: `go test ./internal/server/http/... && go vet ./...`
|
|
Expected: PASS, no vet output. Fix any test that broke because
|
|
of the new column (existing dashboard tests may need a column-
|
|
count update).
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add internal/server/http/ui_handlers.go \
|
|
internal/server/http/ui_dashboard_test.go \
|
|
web/templates/partials/host_row.html \
|
|
web/templates/pages/dashboard.html
|
|
git commit -m "ui: 30d repo-size sparkline on every dashboard host row"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Host repo page Trend panel + range selector
|
|
|
|
**Files:**
|
|
- Create: `web/templates/partials/repo_size_chart.html`
|
|
- Create: `internal/server/http/ui_repo_trend.go`
|
|
- Modify: `internal/server/http/ui_repo.go` — pre-render initial chart
|
|
- Modify: `internal/server/http/server.go` — mount route
|
|
- Modify: `web/templates/pages/host_repo.html` — Trend panel
|
|
- Test: `internal/server/http/ui_repo_test.go` — extend
|
|
- Test: `internal/server/http/ui_repo_trend_test.go` — new
|
|
|
|
- [ ] **Step 1: Write the failing test for the trend endpoint**
|
|
|
|
Create `internal/server/http/ui_repo_trend_test.go`:
|
|
|
|
```go
|
|
package http
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
func TestUIRepoTrend_30dRange(t *testing.T) {
|
|
t.Parallel()
|
|
srv := newTestServer(t)
|
|
hostID := srv.seedHost(t, "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 := srv.deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
|
store.HostRepoStats{TotalSizeBytes: &v, SnapshotCount: &c}, now); err != nil {
|
|
t.Fatalf("seed %s: %v", day, err)
|
|
}
|
|
}
|
|
|
|
body := srv.getHTML(t, "/hosts/"+hostID+"/repo/trend?range=30d")
|
|
if !strings.Contains(body, `class="repo-trend-chart"`) {
|
|
t.Errorf("expected repo-trend-chart SVG in fragment, missing")
|
|
}
|
|
if !strings.Contains(body, `id="repo-trend-chart"`) {
|
|
t.Errorf("expected outer wrapper with id repo-trend-chart")
|
|
}
|
|
}
|
|
|
|
func TestUIRepoTrend_InvalidRangeFallsBackTo30d(t *testing.T) {
|
|
t.Parallel()
|
|
srv := newTestServer(t)
|
|
hostID := srv.seedHost(t, "h-trend2")
|
|
|
|
body := srv.getHTML(t, "/hosts/"+hostID+"/repo/trend?range=banana")
|
|
if !strings.Contains(body, `data-range="30d"`) {
|
|
t.Errorf("expected data-range=30d on fallback, body: %s", body)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/server/http -run TestUIRepoTrend -v`
|
|
Expected: FAIL — 404 (route not mounted).
|
|
|
|
- [ ] **Step 3: Implement the trend handler**
|
|
|
|
Create `internal/server/http/ui_repo_trend.go`:
|
|
|
|
```go
|
|
package http
|
|
|
|
import (
|
|
"math"
|
|
stdhttp "net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline"
|
|
)
|
|
|
|
// handleUIRepoTrend renders the trend chart fragment for the repo
|
|
// page. Returns 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
|
|
func (s *Server) handleUIRepoTrend(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
|
|
rangeKey := r.URL.Query().Get("range")
|
|
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(r.Context(), hostID, since)
|
|
if err != nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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})
|
|
|
|
ui.Render(w, r, "repo_size_chart", map[string]any{
|
|
"HostID": hostID,
|
|
"Range": rangeKey,
|
|
"ChartSVG": chartSVG,
|
|
})
|
|
}
|
|
```
|
|
|
|
If `ui.Render` is not the project's render helper, replace with
|
|
the actual rendering call used elsewhere — check
|
|
`internal/server/http/ui_repo.go` to see the canonical pattern
|
|
(likely something like `s.render(w, r, "...", data)`). Adopt
|
|
whatever is already used.
|
|
|
|
- [ ] **Step 4: Mount the route**
|
|
|
|
In `internal/server/http/server.go`, in the auth-gated UI block
|
|
(near the existing `r.Get("/hosts/{id}/repo", s.handleUIHostRepo)`
|
|
on line 198), add:
|
|
|
|
```go
|
|
r.Get("/hosts/{id}/repo/trend", s.handleUIRepoTrend)
|
|
```
|
|
|
|
- [ ] **Step 5: Create the chart partial**
|
|
|
|
Create `web/templates/partials/repo_size_chart.html`:
|
|
|
|
```html
|
|
{{define "repo_size_chart"}}
|
|
<div id="repo-trend-chart" data-range="{{.Range}}">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-xs text-ink-mid">Range:</span>
|
|
{{range $r := slice "30d" "90d" "1y"}}
|
|
<a class="btn btn-ghost-xs {{if eq $r $.Range}}is-active{{end}}"
|
|
hx-get="/hosts/{{$.HostID}}/repo/trend?range={{$r}}"
|
|
hx-target="#repo-trend-chart"
|
|
hx-swap="outerHTML">{{$r}}</a>
|
|
{{end}}
|
|
</div>
|
|
<div class="text-ink">{{.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}}
|
|
```
|
|
|
|
If the project's templates don't use `slice`, hard-code three
|
|
anchors. Confirm template-func availability before keeping the
|
|
loop.
|
|
|
|
- [ ] **Step 6: Insert the Trend panel on the repo page**
|
|
|
|
Open `web/templates/pages/host_repo.html`. Find the existing
|
|
summary panel — likely the first `<section>` or `<div class="panel">`
|
|
that renders `StatsView`. Immediately after it (and before the
|
|
maintenance panel), insert:
|
|
|
|
```html
|
|
<section class="panel">
|
|
<h3 class="panel-title">Trend</h3>
|
|
{{template "repo_size_chart" .Trend}}
|
|
</section>
|
|
```
|
|
|
|
- [ ] **Step 7: Pre-render the initial chart in `loadHostRepoPage`**
|
|
|
|
In `internal/server/http/ui_repo.go`, extend `hostRepoPage`:
|
|
|
|
```go
|
|
type hostRepoPage struct {
|
|
hostChromeData
|
|
// ... existing fields ...
|
|
Trend repoTrendView
|
|
}
|
|
|
|
type repoTrendView struct {
|
|
HostID string
|
|
Range string
|
|
ChartSVG template.HTML
|
|
}
|
|
```
|
|
|
|
Add the `html/template` import if missing.
|
|
|
|
In `loadHostRepoPage` (or wherever the page struct is populated
|
|
for `handleUIHostRepo`), build the initial 30d chart by calling
|
|
the same helper as the trend endpoint. Factor the build out into
|
|
a small private helper:
|
|
|
|
```go
|
|
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}
|
|
}
|
|
```
|
|
|
|
Replace the duplicate body inside `handleUIRepoTrend` with a call
|
|
to this helper too — DRY.
|
|
|
|
In `loadHostRepoPage`, set:
|
|
|
|
```go
|
|
page.Trend = s.buildRepoTrendView(r.Context(), hostID, "30d")
|
|
```
|
|
|
|
- [ ] **Step 8: Run tests to verify they pass**
|
|
|
|
Run: `go test ./internal/server/http -run TestUIRepoTrend -v`
|
|
Expected: PASS for both subtests.
|
|
|
|
- [ ] **Step 9: Run full http package + vet**
|
|
|
|
Run: `go test ./internal/server/http/... && go vet ./...`
|
|
Expected: PASS, no vet output.
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add internal/server/http/ui_repo_trend.go \
|
|
internal/server/http/ui_repo_trend_test.go \
|
|
internal/server/http/ui_repo.go \
|
|
internal/server/http/server.go \
|
|
web/templates/partials/repo_size_chart.html \
|
|
web/templates/pages/host_repo.html
|
|
git commit -m "ui: trend panel + range selector on host repo page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Build, restage smoke env, smoke check
|
|
|
|
**Files:**
|
|
- Modify: `tasks.md` — flip P6-03 to `[x]` with as-shipped block
|
|
|
|
- [ ] **Step 1: Build all binaries**
|
|
|
|
Run: `make build`
|
|
Expected: server + agent binaries land in `bin/`.
|
|
|
|
- [ ] **Step 2: Restage smoke env**
|
|
|
|
Per CLAUDE.md, run the full restage block (server-side change
|
|
only, no agent or unit-file edit, so the agent restage is
|
|
optional but harmless):
|
|
|
|
```bash
|
|
cp bin/restic-manager-agent \
|
|
$HOME/smoke/data/agent-binaries/restic-manager-agent-linux-amd64
|
|
cp deploy/install/install.sh \
|
|
$HOME/smoke/data/install/install.sh
|
|
cp deploy/install/install.ps1 \
|
|
$HOME/smoke/data/install/install.ps1
|
|
cp deploy/install/restic-manager-agent.service \
|
|
$HOME/smoke/data/install/restic-manager-agent.service
|
|
|
|
pkill -f restic-manager-server
|
|
RM_LISTEN=:8080 RM_DATA_DIR=$HOME/smoke/data \
|
|
RM_BASE_URL=http://127.0.0.1:8080 \
|
|
RM_SECRET_KEY_FILE=$HOME/smoke/data/secret.key \
|
|
RM_COOKIE_SECURE=false \
|
|
./bin/restic-manager-server >> $HOME/smoke/server.log 2>&1 &
|
|
```
|
|
|
|
- [ ] **Step 3: Smoke verify the dashboard**
|
|
|
|
In a logged-in browser session at `http://127.0.0.1:8080/`:
|
|
|
|
- Confirm a new `30d trend` column appears between Repo size and
|
|
Snapshots.
|
|
- Hosts with no history rows show the em-dash placeholder.
|
|
- Trigger a backup on a connected host. After it lands and the
|
|
dashboard's 5s live-poll fires, that host's sparkline cell now
|
|
shows a single point — placeholder still visible (need ≥2
|
|
points). Run a second backup the next day (or insert a manual
|
|
history row via SQLite if testing same-day) to confirm the
|
|
polyline draws.
|
|
|
|
- [ ] **Step 4: Smoke verify the repo page**
|
|
|
|
Navigate to `/hosts/<id>/repo`:
|
|
|
|
- Confirm the new "Trend" panel renders between summary and
|
|
maintenance.
|
|
- Click each of the `30d | 90d | 1y` pills — the chart fragment
|
|
swaps in place without a full reload (network tab will show a
|
|
single htmx GET per click).
|
|
- Hover a data dot — the native tooltip shows `<date · series:
|
|
value>`.
|
|
|
|
- [ ] **Step 5: Update tasks.md**
|
|
|
|
In `tasks.md`, change P6-03 line to `[x]` and add an as-shipped
|
|
note immediately after the bullet, mirroring the P6-01/P6-02 block
|
|
above it. Suggested text:
|
|
|
|
```markdown
|
|
> **As shipped (2026-05-07, branch `<this-branch>`):**
|
|
> 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).
|
|
> WS handler in `internal/server/ws/handler.go` writes a history
|
|
> row alongside the existing `UpsertHostRepoStats` call. New
|
|
> `internal/web/sparkline` package emits inline SVG (sparkline +
|
|
> two-axis chart). Dashboard host row gains a 30d sparkline cell
|
|
> next to repo size; host repo page gains a Trend panel with
|
|
> `30d | 90d | 1y` server-rendered range selector. No new
|
|
> dependencies, no client JS, no agent change.
|
|
```
|
|
|
|
- [ ] **Step 6: Commit and verify clean state**
|
|
|
|
```bash
|
|
git add tasks.md
|
|
git commit -m "tasks: P6-03 done, repo size trend graphs"
|
|
git status
|
|
```
|
|
|
|
Expected: working tree clean.
|
|
|
|
---
|
|
|
|
## Self-review notes (resolved inline)
|
|
|
|
- All spec sections (schema, write path, read path, rendering,
|
|
placement, endpoints, testing) map to a task.
|
|
- No placeholders.
|
|
- Type names consistent across tasks: `RepoStatsHistoryPoint`,
|
|
`Series`, `Axis`, `Format`, `ChartOpts`, `dashboardHostRow`,
|
|
`repoTrendView`.
|
|
- The DRY helper `buildRepoTrendView` is reused by both the
|
|
page-load handler and the htmx fragment endpoint.
|
|
- COALESCE behaviour in the upsert is exercised by Task 2's
|
|
first test (the prune-only third write must preserve prior
|
|
size + count).
|