Merge pull request 'P6-03 repo size trend + agent-update UI fix + dashboard polish' (#21) from tidy-up-last-backup-projection into main

Reviewed-on: #21
This commit is contained in:
2026-05-07 22:00:03 +00:00
39 changed files with 1691 additions and 16 deletions
+23 -2
View File
@@ -81,8 +81,29 @@ RM_COOKIE_SECURE=false \
./bin/restic-manager-server >> $HOME/smoke/server.log 2>&1 & ./bin/restic-manager-server >> $HOME/smoke/server.log 2>&1 &
``` ```
A `make smoke-deploy` target that bundles all of this would be a ## Smoke server: use the Make targets, not raw `nohup`
good follow-up.
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 ## Migrations: prefer column-level ALTERs over table rebuilds
+54 -1
View File
@@ -24,7 +24,18 @@ TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/downlo
TAILWIND_INPUT := web/styles/input.css TAILWIND_INPUT := web/styles/input.css
TAILWIND_OUTPUT := web/static/css/styles.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: help:
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "};{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' @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) \ --build-arg DATE=$(DATE) \
-t $(DOCKER_IMAGE):$(DOCKER_TAG) . -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 release: ## Cross-compile for all supported platforms
@mkdir -p $(BIN_DIR) @mkdir -p $(BIN_DIR)
@for target in linux/amd64 linux/arm64 windows/amd64; do \ @for target in linux/amd64 linux/arm64 windows/amd64; do \
+1 -1
View File
@@ -92,7 +92,7 @@ func run() error {
notifHub := notification.NewHub(st, aead, cfg.BaseURL) notifHub := notification.NewHub(st, aead, cfg.BaseURL)
alertEngine := alert.NewEngine(st, notifHub) alertEngine := alert.NewEngine(st, notifHub)
updateWatcher := ws.NewUpdateWatcher(st, alertEngine) updateWatcher := ws.NewUpdateWatcher(st, alertEngine, jobHub)
renderer, err := ui.New() renderer, err := ui.New()
if err != nil { if err != nil {
+2
View File
@@ -195,7 +195,9 @@ func (s *Server) routes(r chi.Router) {
r.Get("/hosts/{id}/sources", s.handleUIHostSources) r.Get("/hosts/{id}/sources", s.handleUIHostSources)
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) 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", s.handleUIHostRepo)
r.Get("/hosts/{id}/repo/trend", s.handleUIRepoTrend)
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet) r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
@@ -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, `<polyline`) {
t.Errorf("expected <polyline> 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")
}
}
+21
View File
@@ -5,8 +5,10 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"html/template"
"io/fs" "io/fs"
"log/slog" "log/slog"
"math"
stdhttp "net/http" stdhttp "net/http"
"net/url" "net/url"
"sort" "sort"
@@ -24,6 +26,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "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/store"
"gitea.dcglab.co.uk/steve/restic-manager/internal/version" "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" "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 // TargetVersion is the server's build version, surfaced in the
// chip's tooltip and label. // chip's tooltip and label.
TargetVersion string 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 // 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) rows = append(rows, row)
} }
+47
View File
@@ -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)
}
}
+85
View File
@@ -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)
}
+60
View File
@@ -1,9 +1,12 @@
package http package http
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"html/template"
"log/slog" "log/slog"
"math"
stdhttp "net/http" stdhttp "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -13,6 +16,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "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/store"
"gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline"
) )
// ui_repo.go — HTML form-driven repo-tab handlers (connection, // 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 — admin (prune) creds
// POST /hosts/{id}/admin-credentials/delete — clear admin 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 // repoStatsView is a flat, pre-dereferenced projection of
// store.HostRepoStats for use in templates. Nil pointer fields are // store.HostRepoStats for use in templates. Nil pointer fields are
// collapsed to zero/false and accompanied by a Has* sentinel so the // 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). // Nil when no row exists yet (fresh hosts).
StatsView *repoStatsView 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. // Snapshots-by-tag — map[group_name]count, plus an "untagged" row.
SnapshotsByTag map[string]int SnapshotsByTag map[string]int
UntaggedSnapshots 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 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: 640, Height: 220})
return repoTrendView{HostID: hostID, Range: rangeKey, ChartSVG: chartSVG}
}
func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r) u := s.requireUIUser(w, r)
if u == nil { 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)
}
}
+123
View File
@@ -0,0 +1,123 @@
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")
}
}
// 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")
}
}
+22
View File
@@ -75,6 +75,28 @@ func funcMap() template.FuncMap {
return *p return *p
}, },
"sub": func(a, b int) int { return a - b }, "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 // joinComma joins a slice with ", ". Used by the schedule list
// to render retention summaries. // to render retention summaries.
"joinComma": func(parts []string) string { return strings.Join(parts, ", ") }, "joinComma": func(parts []string) string { return strings.Join(parts, ", ") },
+1
View File
@@ -110,6 +110,7 @@ func New() (*Renderer, error) {
"templates/partials/crit_banner.html", "templates/partials/crit_banner.html",
"templates/partials/fleet_update_inner.html", "templates/partials/fleet_update_inner.html",
"templates/partials/host_update_chip.html", "templates/partials/host_update_chip.html",
"templates/partials/repo_size_chart.html",
} }
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
+4
View File
@@ -339,6 +339,10 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
} else { } else {
slog.Info("ws: repo stats refreshed", "host_id", hostID) 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: case api.MsgCommandResult:
// TODO(P2): persist command.result acks for "did the agent // TODO(P2): persist command.result acks for "did the agent
+39
View File
@@ -133,3 +133,42 @@ func TestRepoStatsReportPartialUpdate(t *testing.T) {
t.Errorf("LastCheckStatus: got %q want ok", got.LastCheckStatus) 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)
}
}
+34 -1
View File
@@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
) )
@@ -29,6 +30,7 @@ type AlertRaiser interface {
type UpdateWatcher struct { type UpdateWatcher struct {
store *store.Store store *store.Store
alerts AlertRaiser alerts AlertRaiser
jobHub *JobHub // optional — if nil, no fan-out to browser streams
mu sync.Mutex mu sync.Mutex
entries map[string]*updateEntry // hostID → entry entries map[string]*updateEntry // hostID → entry
@@ -46,10 +48,11 @@ type updateEntry struct {
// NewUpdateWatcher builds an unstarted watcher. Call Run in a goroutine // NewUpdateWatcher builds an unstarted watcher. Call Run in a goroutine
// to start the periodic sweep. // 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{ return &UpdateWatcher{
store: st, store: st,
alerts: alerts, alerts: alerts,
jobHub: jobHub,
entries: make(map[string]*updateEntry), entries: make(map[string]*updateEntry),
tickPeriod: 5 * time.Second, 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 { 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) 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 { if w.alerts != nil {
w.alerts.ResolveUpdateFailed(ctx, hostID, now) 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 { 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) 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 { if w.alerts != nil {
w.alerts.RaiseUpdateFailed(ctx, x.hostID, x.jobID, reason, stamp) 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)
}
+73 -4
View File
@@ -8,6 +8,7 @@ import (
"github.com/oklog/ulid/v2" "github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
) )
@@ -50,7 +51,7 @@ func TestUpdateWatcherOnHelloSuccess(t *testing.T) {
jobID := seedJob(t, st, hostID) jobID := seedJob(t, st, hostID)
a := &fakeAlerts{} a := &fakeAlerts{}
w := NewUpdateWatcher(st, a) w := NewUpdateWatcher(st, a, nil)
w.Track(jobID, hostID) w.Track(jobID, hostID)
w.OnHello(context.Background(), hostID, "v2", "v2") w.OnHello(context.Background(), hostID, "v2", "v2")
@@ -83,7 +84,7 @@ func TestUpdateWatcherTimeout(t *testing.T) {
jobID := seedJob(t, st, hostID) jobID := seedJob(t, st, hostID)
a := &fakeAlerts{} a := &fakeAlerts{}
w := NewUpdateWatcher(st, a) w := NewUpdateWatcher(st, a, nil)
w.Track(jobID, hostID) w.Track(jobID, hostID)
time.Sleep(80 * time.Millisecond) time.Sleep(80 * time.Millisecond)
@@ -113,7 +114,7 @@ func TestUpdateWatcherMismatchedVersionNoOp(t *testing.T) {
jobID := seedJob(t, st, hostID) jobID := seedJob(t, st, hostID)
a := &fakeAlerts{} a := &fakeAlerts{}
w := NewUpdateWatcher(st, a) w := NewUpdateWatcher(st, a, nil)
w.Track(jobID, hostID) w.Track(jobID, hostID)
w.OnHello(context.Background(), hostID, "v1", "v2") w.OnHello(context.Background(), hostID, "v1", "v2")
@@ -140,7 +141,7 @@ func TestUpdateWatcherHelloAfterTimeoutIsNoOp(t *testing.T) {
jobID := seedJob(t, st, hostID) jobID := seedJob(t, st, hostID)
a := &fakeAlerts{} a := &fakeAlerts{}
w := NewUpdateWatcher(st, a) w := NewUpdateWatcher(st, a, nil)
w.Track(jobID, hostID) w.Track(jobID, hostID)
time.Sleep(80 * time.Millisecond) time.Sleep(80 * time.Millisecond)
@@ -159,3 +160,71 @@ func TestUpdateWatcherHelloAfterTimeoutIsNoOp(t *testing.T) {
t.Fatalf("late hello triggered ResolveUpdateFailed: %v", a.resolved) 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")
}
}
+15
View File
@@ -211,6 +211,21 @@ func (s *Store) UpsertHostRepoStats(ctx context.Context, hostID string, patch Ho
); err != nil { ); err != nil {
return fmt.Errorf("store: upsert host_repo_stats: %w", err) 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() return tx.Commit()
} }
+98
View File
@@ -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
}
@@ -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))
}
}
+81
View File
@@ -288,6 +288,87 @@ func (s *Store) HasJobOfKind(ctx context.Context, hostID, kind string) (bool, er
return n > 0, nil 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 { func nullableStr(s string) any {
if s == "" { if s == "" {
return nil return nil
+83
View File
@@ -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)
}
}
@@ -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);
+355
View File
@@ -0,0 +1,355 @@
// 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
}
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()
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())
}
// 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 <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. Points
// beyond len(days) are ignored.
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 = 72, 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())
}
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)
}
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,
`<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 i >= len(days) {
break
}
if math.IsNaN(v) {
flush()
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]
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()
}
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,
`<text x="14" y="%d" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity="0.55" transform="rotate(-90, 14, %d)">Size</text>`,
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,
`<text x="%d" y="%d" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity="0.55" transform="rotate(90, %d, %d)">Snapshots</text>`,
w-14, cy, w-14, cy)
}
// 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,
`<text x="%.2f" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
x, h-padB+16, l.anchor, days[l.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])
}
+74
View File
@@ -0,0 +1,74 @@
package sparkline
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
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)
}
}
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)
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time"><line x1="72" y1="110" x2="544" y2="110" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/><text x="300" y="114" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">no data yet</text></svg>

After

Width:  |  Height:  |  Size: 381 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time"><line x1="72" y1="16" x2="544" y2="16" stroke="currentColor" stroke-opacity="0.08"/><line x1="72" y1="60" x2="544" y2="60" stroke="currentColor" stroke-opacity="0.08"/><line x1="72" y1="104" x2="544" y2="104" stroke="currentColor" stroke-opacity="0.08"/><line x1="72" y1="148" x2="544" y2="148" stroke="currentColor" stroke-opacity="0.08"/><line x1="72" y1="192" x2="544" y2="192" stroke="currentColor" stroke-opacity="0.08"/><circle cx="72.00" cy="192.00" r="2.5" fill="#3b82f6"><title>2026-05-01 · size: 1.0 KiB</title></circle><circle cx="229.33" cy="166.86" r="2.5" fill="#3b82f6"><title>2026-05-02 · size: 2.0 KiB</title></circle><circle cx="386.67" cy="116.57" r="2.5" fill="#3b82f6"><title>2026-05-03 · size: 4.0 KiB</title></circle><circle cx="544.00" cy="16.00" r="2.5" fill="#3b82f6"><title>2026-05-04 · size: 8.0 KiB</title></circle><polyline fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="72.00,192.00 229.33,166.86 386.67,116.57 544.00,16.00"/><circle cx="72.00" cy="192.00" r="2.5" fill="#f59e0b"><title>2026-05-01 · snapshots: 1</title></circle><circle cx="229.33" cy="133.33" r="2.5" fill="#f59e0b"><title>2026-05-02 · snapshots: 2</title></circle><circle cx="386.67" cy="74.67" r="2.5" fill="#f59e0b"><title>2026-05-03 · snapshots: 3</title></circle><circle cx="544.00" cy="16.00" r="2.5" fill="#f59e0b"><title>2026-05-04 · snapshots: 4</title></circle><polyline fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="72.00,192.00 229.33,133.33 386.67,74.67 544.00,16.00"/><text x="66" y="19" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">8.0 KiB</text><text x="66" y="63" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">6.2 KiB</text><text x="66" y="107" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">4.5 KiB</text><text x="66" y="151" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">2.8 KiB</text><text x="66" y="195" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">1.0 KiB</text><text x="14" y="104" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity="0.55" transform="rotate(-90, 14, 104)">Size</text><text x="550" y="19" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">4</text><text x="550" y="63" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">3</text><text x="550" y="107" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">2</text><text x="550" y="151" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">2</text><text x="550" y="195" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">1</text><text x="586" y="104" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity="0.55" transform="rotate(90, 586, 104)">Snapshots</text><text x="72.00" y="208" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">May 1</text><text x="386.67" y="208" text-anchor="middle" font-size="10" fill="currentColor" fill-opacity="0.55">May 3</text><text x="544.00" y="208" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">May 4</text></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20" class="repo-sparkline" role="img" aria-label="repo size trend"><line x1="2" y1="10" x2="78" y2="10" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/><text x="40" y="14" text-anchor="middle" font-size="14" fill="currentColor" fill-opacity="0.4"></text></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20" class="repo-sparkline" role="img" aria-label="repo size trend"><line x1="2" y1="10" x2="78" y2="10" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/><text x="40" y="14" text-anchor="middle" font-size="14" fill="currentColor" fill-opacity="0.4"></text></svg>

After

Width:  |  Height:  |  Size: 340 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20" class="repo-sparkline" role="img" aria-label="repo size trend"><polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="2.00,18.00 40.00,2.00 78.00,10.00"/><title>current 20, +10 over window</title></svg>

After

Width:  |  Height:  |  Size: 327 B

+19 -1
View File
@@ -371,7 +371,25 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
> `v0.9.0-11-gccaccd8-dirty``v9.9.9-smoke` in <5s; `.old` preserved > `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. > on disk; chip and hero tile cleared on reconnect; audit row landed.
> Screenshots in `_diag/p6-update-sweep/`. > 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-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.)_ - [ ] **P6-05** (S) Document Prometheus integration + sample Grafana dashboard JSON. _(Was P4-09.)_
File diff suppressed because one or more lines are too long
+26 -1
View File
@@ -219,7 +219,7 @@
/* ---------- host row (the dashboard's load-bearing component) ---------- */ /* ---------- host row (the dashboard's load-bearing component) ---------- */
.host-row { .host-row {
display: grid; align-items: center; 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; column-gap: 18px;
padding: 11px 16px; font-size: 13px; padding: 11px 16px; font-size: 13px;
border-left: 3px solid transparent; border-left: 3px solid transparent;
@@ -439,6 +439,31 @@
.schd-row.clickable > .row-link { pointer-events: auto; } .schd-row.clickable > .row-link { pointer-events: auto; }
.schd-row.clickable > .row-action { 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 ---------- */ /* ---------- cron preset chips ---------- */
.preset-chip { .preset-chip {
font-family: 'JetBrains Mono', monospace; font-size: 11.5px; font-family: 'JetBrains Mono', monospace; font-size: 11.5px;
+3 -2
View File
@@ -213,9 +213,10 @@
<div><a href="{{index $sortURL "os"}}" class="text-ink-mid hover:text-ink">OS · arch{{if eq $f.Sort "os"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div> <div><a href="{{index $sortURL "os"}}" class="text-ink-mid hover:text-ink">OS · arch{{if eq $f.Sort "os"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div>
<div><a href="{{index $sortURL "last_backup"}}" class="text-ink-mid hover:text-ink">Last backup{{if eq $f.Sort "last_backup"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div> <div><a href="{{index $sortURL "last_backup"}}" class="text-ink-mid hover:text-ink">Last backup{{if eq $f.Sort "last_backup"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div>
<div class="text-right"><a href="{{index $sortURL "repo_size"}}" class="text-ink-mid hover:text-ink">Repo size{{if eq $f.Sort "repo_size"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div> <div class="text-right"><a href="{{index $sortURL "repo_size"}}" class="text-ink-mid hover:text-ink">Repo size{{if eq $f.Sort "repo_size"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div>
<div class="text-ink-mid">30d trend</div>
<div class="text-right"><a href="{{index $sortURL "snapshot_count"}}" class="text-ink-mid hover:text-ink">Snapshots{{if eq $f.Sort "snapshot_count"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div> <div class="text-right"><a href="{{index $sortURL "snapshot_count"}}" class="text-ink-mid hover:text-ink">Snapshots{{if eq $f.Sort "snapshot_count"}} {{if eq $f.Dir "desc"}}↓{{else}}↑{{end}}{{end}}</a></div>
<div>Alerts</div> <div class="text-ink-mid text-right">Alerts</div>
<div>Tags</div> <div class="text-ink-mid">Tags</div>
<div></div> <div></div>
</div> </div>
+65
View File
@@ -0,0 +1,65 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
<div class="flex items-center justify-between mb-4">
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] max-w-[760px]">
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.
</p>
</div>
{{if eq (len $page.Jobs) 0}}
<div class="panel rounded-[7px] empty-state" style="border-radius: 7px;">
<h3 class="text-base font-medium tracking-[-0.005em]">No jobs yet.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
Trigger a backup from the Sources tab, or wait for a schedule to fire — jobs appear here as soon as they're queued.
</p>
</div>
{{else}}
<div class="panel rounded-[7px] overflow-hidden">
<div class="jobs-row head hairline">
<div>Kind</div>
<div>Status</div>
<div>Actor</div>
<div>Started</div>
<div>Duration</div>
<div></div>
</div>
{{range $i, $j := $page.Jobs}}
<div class="jobs-row clickable {{if not (eq $i 0)}}hairline{{end}}">
<a href="/jobs/{{$j.ID}}" class="row-link" aria-label="Open job"></a>
<div class="mono text-ink">{{$j.Kind}}</div>
<div>
{{if eq $j.Status "succeeded"}}
<span class="mono text-[11px] text-ok">succeeded</span>
{{else if eq $j.Status "failed"}}
<span class="mono text-[11px] text-bad">failed</span>
{{else if eq $j.Status "cancelled"}}
<span class="mono text-[11px] text-warn">cancelled</span>
{{else if eq $j.Status "running"}}
<span class="mono text-[11px] text-accent">running</span>
{{else}}
<span class="mono text-[11px] text-ink-mid">{{$j.Status}}</span>
{{end}}
</div>
<div class="mono text-[11.5px] text-ink-mid">{{$j.ActorKind}}</div>
<div class="mono text-[11.5px] {{if $j.StartedAt}}text-ink-mid{{else}}text-ink-fade{{end}}"
{{if $j.StartedAt}}title="{{$j.StartedAt.Format "2006-01-02 15:04:05 MST"}}"{{end}}>
{{if $j.StartedAt}}{{relTime $j.StartedAt}}{{else}}<span class="text-ink-fade">queued</span>{{end}}
</div>
<div class="mono text-[11.5px] text-ink-mid">
{{if and $j.StartedAt $j.FinishedAt}}{{durationHuman $j.StartedAt $j.FinishedAt}}{{else}}<span class="text-ink-fade"></span>{{end}}
</div>
<div class="text-right text-ink-fade row-action"></div>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
+6
View File
@@ -245,6 +245,12 @@
</div> </div>
</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 ---------- */}} {{/* ---------- 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> <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"> <form method="post" action="/hosts/{{$host.ID}}/repo/hooks" class="panel rounded-[7px] p-5">
+1 -2
View File
@@ -176,8 +176,7 @@
<a class="sub-tab {{if eq $page.SubTab "sources"}}active{{end}}" href="/hosts/{{$host.ID}}/sources">Sources <span class="mono text-ink-fade text-[11px] ml-1">{{$page.SourceGroupCount}}</span></a> <a class="sub-tab {{if eq $page.SubTab "sources"}}active{{end}}" href="/hosts/{{$host.ID}}/sources">Sources <span class="mono text-ink-fade text-[11px] ml-1">{{$page.SourceGroupCount}}</span></a>
<a class="sub-tab {{if eq $page.SubTab "schedules"}}active{{end}}" href="/hosts/{{$host.ID}}/schedules">Schedules <span class="mono text-ink-fade text-[11px] ml-1">{{$page.ScheduleCount}}</span></a> <a class="sub-tab {{if eq $page.SubTab "schedules"}}active{{end}}" href="/hosts/{{$host.ID}}/schedules">Schedules <span class="mono text-ink-fade text-[11px] ml-1">{{$page.ScheduleCount}}</span></a>
<a class="sub-tab {{if eq $page.SubTab "repo"}}active{{end}}" href="/hosts/{{$host.ID}}/repo">Repo</a> <a class="sub-tab {{if eq $page.SubTab "repo"}}active{{end}}" href="/hosts/{{$host.ID}}/repo">Repo</a>
<div class="sub-tab" title="lands later">Jobs</div> <a class="sub-tab {{if eq $page.SubTab "jobs"}}active{{end}}" href="/hosts/{{$host.ID}}/jobs">Jobs</a>
<div class="sub-tab" title="lands later">Settings</div>
</div> </div>
</div> </div>
{{end}} {{end}}
+1
View File
@@ -35,6 +35,7 @@
{{- end -}} {{- end -}}
</div> </div>
<div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes $h.RepoSizeBytes}}</div> <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}}"> <div class="text-right mono {{if eq $h.Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
{{- if eq $h.SnapshotCount 0 -}} {{- if eq $h.SnapshotCount 0 -}}
<span class="text-ink-fade"></span> <span class="text-ink-fade"></span>
@@ -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}}