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 was merged in pull request #21.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
|||||||
|
# P6-03 — Repo size trend graphs
|
||||||
|
|
||||||
|
Sparkline on the dashboard host row + full chart on the host repo
|
||||||
|
page, both showing repo growth over time. Closes the last
|
||||||
|
operator-visibility gap in Phase 6 alongside Prometheus metrics
|
||||||
|
(P6-04).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Operators can see at a glance whether a host's repo is growing,
|
||||||
|
stable, or shrinking, without leaving the dashboard.
|
||||||
|
- A second screen on the repo page exposes the same data over a
|
||||||
|
longer window with a snapshot-count overlay so retention
|
||||||
|
behaviour can be eyeballed against size.
|
||||||
|
- Zero new client-side dependencies; matches the existing
|
||||||
|
HTMX + server-rendered idiom used everywhere else in the UI.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No backfill of historical data. Trend lights up with whatever
|
||||||
|
the agents report from the day this ships.
|
||||||
|
- No per-source-group breakdown — repo-level only.
|
||||||
|
- No alerting on growth rate (dedicated to a future ticket if a
|
||||||
|
user asks).
|
||||||
|
- No JSON API surface. Prometheus exposure is P6-04, separate.
|
||||||
|
|
||||||
|
## Decisions taken in brainstorming
|
||||||
|
|
||||||
|
- **Metrics:** `total_size_bytes` (sparkline + chart) and
|
||||||
|
`snapshot_count` (chart only). Raw size dropped as redundant.
|
||||||
|
- **Cadence:** one row per `(host_id, UTC date)`, last-write-wins
|
||||||
|
per column. Bounded at ~365 rows/host/year regardless of job
|
||||||
|
frequency.
|
||||||
|
- **Backfill:** none. Pure forward-fill from launch day.
|
||||||
|
- **Rendering:** server-rendered inline SVG, no JS library.
|
||||||
|
- **Spans:** sparkline fixed at 30 days; chart has `30d | 90d | 1y`
|
||||||
|
range selector, server-rendered swap.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
New migration `internal/store/migrations/0023_host_repo_stats_history.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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 don't overwrite
|
||||||
|
snapshot_count INTEGER, -- nullable
|
||||||
|
recorded_at TEXT NOT NULL, -- RFC3339Nano of last write touching this row
|
||||||
|
PRIMARY KEY (host_id, day)
|
||||||
|
);
|
||||||
|
CREATE INDEX host_repo_stats_history_host_day
|
||||||
|
ON host_repo_stats_history(host_id, day DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
FK cascade matches every other host-scoped table; deleting a host
|
||||||
|
through `Store.DeleteHost` (NS-01) wipes its history automatically.
|
||||||
|
|
||||||
|
## Write path
|
||||||
|
|
||||||
|
Hook the existing `MsgRepoStats` handler in
|
||||||
|
`internal/server/ws/handler.go` (around line 319). After the
|
||||||
|
existing `UpsertHostRepoStats(ctx, hostID, patch)` call, append:
|
||||||
|
|
||||||
|
```go
|
||||||
|
day := time.Now().UTC().Format("2006-01-02")
|
||||||
|
if err := deps.Store.UpsertHostRepoStatsHistory(ctx, hostID, day, patch); err != nil {
|
||||||
|
slog.Warn("ws: upsert host repo stats history", "host_id", hostID, "err", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A history-write failure is logged and dropped — never blocks the
|
||||||
|
main upsert. The partial-update contract that
|
||||||
|
`UpsertHostRepoStats` already implements is preserved at the
|
||||||
|
history layer:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is critical: the agent's prune handler in
|
||||||
|
`internal/agent/runner/runner.go:318` emits a stats patch that
|
||||||
|
only carries `LastPruneAt`. Without `COALESCE`, that prune ack
|
||||||
|
would null out a `total_size_bytes` we'd already captured from a
|
||||||
|
backup earlier the same day.
|
||||||
|
|
||||||
|
## Read path
|
||||||
|
|
||||||
|
Two new helpers in `internal/store/host_repo_stats_history.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RepoStatsHistoryPoint struct {
|
||||||
|
Day time.Time // 00:00:00 UTC
|
||||||
|
TotalSizeBytes *int64
|
||||||
|
SnapshotCount *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListHostRepoStatsHistory(
|
||||||
|
ctx context.Context, hostID string, since time.Time,
|
||||||
|
) ([]RepoStatsHistoryPoint, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns rows ordered by `day` ascending where at least one metric
|
||||||
|
is non-null. The renderer connects available points with a
|
||||||
|
straight line — there is no explicit gap representation. A host
|
||||||
|
that was offline for a week shows a single segment spanning the
|
||||||
|
gap, which is the right visual: the repo state didn't change.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
New package `internal/web/sparkline`. Pure Go, no template
|
||||||
|
dependency:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Series struct {
|
||||||
|
Name string
|
||||||
|
Points []float64 // nil-points represented as math.NaN
|
||||||
|
Stroke string // CSS color
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderSparkline(points []float64, width, height int) template.HTML
|
||||||
|
func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
`RenderChart` produces a 600×220 SVG with:
|
||||||
|
|
||||||
|
- Light horizontal gridlines (4 bands).
|
||||||
|
- Two y-axes: bytes (left, blue) and count (right, amber). Each
|
||||||
|
series is normalised against its own axis.
|
||||||
|
- X-axis labels at start, midpoint, and end of the window.
|
||||||
|
- Per-point `<circle>` with a `<title>` for hover tooltips —
|
||||||
|
accessible by default, no JS.
|
||||||
|
- Empty state: faint dashed baseline + centered "no data yet"
|
||||||
|
text.
|
||||||
|
|
||||||
|
Sparkline is 80×20, single blue polyline, single `<title>` on the
|
||||||
|
group element showing `"current → 30d ago"`.
|
||||||
|
|
||||||
|
Two new partials:
|
||||||
|
|
||||||
|
- `web/templates/partials/repo_size_sparkline.html`
|
||||||
|
- `web/templates/partials/repo_size_chart.html`
|
||||||
|
|
||||||
|
Both call into the renderer with the appropriate opts. No
|
||||||
|
inline `<style>` — colours come from existing Tailwind palette
|
||||||
|
classes already used elsewhere (`text-blue-500`, `text-amber-500`).
|
||||||
|
|
||||||
|
## UI placement
|
||||||
|
|
||||||
|
### Dashboard host row
|
||||||
|
|
||||||
|
`web/templates/partials/host_row.html` gains one `<td>` between
|
||||||
|
the existing "Repo size" cell and "Snapshots" cell. Width ≈ 88px.
|
||||||
|
Cell renders the sparkline partial; if `len(points) < 2` the cell
|
||||||
|
shows "—" centred (matches the existing no-data idiom for
|
||||||
|
last-backup time in the same partial).
|
||||||
|
|
||||||
|
The dashboard's existing 5-second htmx live-refresh
|
||||||
|
(`hx-trigger="every 5s ..."` from NS-04) re-renders this cell
|
||||||
|
along with the rest of the row. No extra polling.
|
||||||
|
|
||||||
|
### Host repo page
|
||||||
|
|
||||||
|
`web/templates/pages/host_repo.html` gains a "Trend" panel
|
||||||
|
inserted between the existing summary panel and the maintenance
|
||||||
|
panel. Panel contains:
|
||||||
|
|
||||||
|
- Range pills `30d | 90d | 1y` (anchor links with
|
||||||
|
`hx-get="/hosts/{id}/repo/trend?range=…"` and
|
||||||
|
`hx-target="#repo-trend-chart" hx-swap="outerHTML"`).
|
||||||
|
- The chart partial wrapped in `<div id="repo-trend-chart">`.
|
||||||
|
- A small legend strip below the chart.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /hosts/{id}/repo/trend?range=30d|90d|1y` — admin/operator,
|
||||||
|
htmx fragment, returns the chart partial. Auth reuses the
|
||||||
|
existing host-scoped middleware on the `/hosts/{id}` family.
|
||||||
|
Invalid `range` falls back to 30d.
|
||||||
|
|
||||||
|
No new admin-only surface — anyone with read access to the host
|
||||||
|
can see the trend.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/store/host_repo_stats_history_test.go` — upsert
|
||||||
|
merges partial patches without nulling; ordering; since-day
|
||||||
|
filter; cascade on host delete.
|
||||||
|
- `internal/web/sparkline/sparkline_test.go` — golden SVG files
|
||||||
|
for: empty input, single point, full 30-day series, mixed
|
||||||
|
null points. Goldens live under `testdata/`.
|
||||||
|
- `internal/server/http/ui_repo_test.go` — trend panel renders
|
||||||
|
with seeded history; range selector swaps server-side; empty
|
||||||
|
state.
|
||||||
|
- `internal/server/http/ui_dashboard_test.go` — host row sparkline
|
||||||
|
cell present and renders SVG when points exist, "—" when not.
|
||||||
|
- Smoke after build: dashboard row shows sparkline once two days
|
||||||
|
of data exist; repo page chart toggles cleanly between ranges.
|
||||||
|
|
||||||
|
## Migration / rollout
|
||||||
|
|
||||||
|
- Schema migration is additive — no risk to existing tables.
|
||||||
|
- Write path is best-effort; on schema issue the main repo-stats
|
||||||
|
upsert is unaffected.
|
||||||
|
- No agent change required, so no fleet update needed.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- After two days of operation, the dashboard sparkline shows a
|
||||||
|
visible line for any host that has run a backup or
|
||||||
|
maintenance op on both days.
|
||||||
|
- Host repo page renders the trend panel with the snapshot-count
|
||||||
|
overlay; range selector switches view without a full page
|
||||||
|
reload.
|
||||||
|
- `go test ./...` and `go vet ./...` clean.
|
||||||
|
- Smoke env exercise: backup → sparkline updates; range pills
|
||||||
|
swap; FK cascade verified by deleting a host and checking the
|
||||||
|
history table.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// ui_repo_trend.go — htmx fragment endpoint for the repo-page
|
||||||
|
// trend chart. Returns just the chart partial wrapped in
|
||||||
|
// <div id="repo-trend-chart"> so htmx can outerHTML-swap it.
|
||||||
|
//
|
||||||
|
// GET /hosts/{id}/repo/trend?range=30d|90d|1y
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdhttp "net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleUIRepoTrend(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
u := s.requireUIUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
view := s.baseView(r, u)
|
||||||
|
view.Page = s.buildRepoTrendView(r.Context(), hostID, r.URL.Query().Get("range"))
|
||||||
|
if err := s.deps.UI.RenderPartial(w, "repo_size_chart", view); err != nil {
|
||||||
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ", ") },
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
@@ -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 |
@@ -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 |
@@ -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 |
@@ -371,7 +371,25 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user