Files
restic-manager/internal/store/fleet.go
T
steve ee16bc7ce7
CI / Test (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (windows/amd64) (push) Has been cancelled
CI / Build (linux/amd64) (push) Has been cancelled
CI / Build (linux/arm64) (push) Has been cancelled
P1-24: live dashboard — fleet summary tiles + host table
Server-rendered HTML view backed by:
  - new store.FleetSummary aggregating host counts + repo bytes +
    snapshot total + open alerts + last-24h job rollup in two queries.
  - GET /api/hosts (JSON list of hosts in the dashboard projection).
  - GET /api/fleet/summary (JSON aggregate, same shape as above).

The HTML page (web/templates/pages/dashboard.html) renders the four
summary tiles + host table directly from store data — no separate
fetch. Per-row state colour comes from .host-row.{degraded,failed,
offline} which paint a 3px left edge so problem hosts are scannable
without reading. HTMX is loaded into the base layout so per-row
"Run now" buttons can hx-post to /hosts/{id}/run-backup, a thin
HTML wrapper that funnels into a new dispatchJob helper shared
with the JSON /api/hosts/{id}/jobs endpoint.

Empty state (zero hosts) collapses to the "no hosts yet" prompt
with the + Add host CTA — matches the v1 mockup.

Template helpers (internal/server/ui/funcs.go) added for byte
formatting (412 GB / 3.7 TB), relative time (3m ago / 2d ago), and
comma grouping (1,847). Pure Go, no template-magic dependency.

Browser-verified end-to-end with seeded fixture data: five hosts
across all four states render with correct dots, accents, last-
backup pills, sizes, snapshot counts, alerts, tags, and the right
action button (Run now / Retry / Run first / View → / offline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:29:11 +01:00

79 lines
2.2 KiB
Go

package store
import (
"context"
"fmt"
"time"
)
// FleetSummary is the aggregated view that powers the dashboard's
// summary tiles. All numbers are point-in-time snapshots; the
// caller polls.
type FleetSummary struct {
TotalHosts int
HostsOnline int
HostsDegraded int
HostsOffline int
RepoBytesTotal int64
SnapshotsTotal int
OpenAlerts int
// Last-24h job rollup. JobsTotal includes every status; the
// breakdown sums to it (modulo any in-flight queued/running).
JobsLast24h int
JobsLast24hSucceeded int
JobsLast24hFailed int
JobsLast24hCancelled int
}
// FleetSummary aggregates host + job stats in two queries. Cheap on
// SQLite at Phase 1 scale (12 hosts, a few hundred jobs/day) — no
// caching layer; the tile is regenerated on every dashboard render.
func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
var fs FleetSummary
row := s.db.QueryRowContext(ctx, `
SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN status = 'online' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'offline' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(repo_size_bytes), 0),
COALESCE(SUM(snapshot_count), 0),
COALESCE(SUM(open_alert_count), 0)
FROM hosts`)
if err := row.Scan(
&fs.TotalHosts, &fs.HostsOnline, &fs.HostsDegraded, &fs.HostsOffline,
&fs.RepoBytesTotal, &fs.SnapshotsTotal, &fs.OpenAlerts,
); err != nil {
return FleetSummary{}, fmt.Errorf("store: fleet summary hosts: %w", err)
}
cutoff := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339Nano)
rows, err := s.db.QueryContext(ctx,
`SELECT status, COUNT(*) FROM jobs WHERE created_at > ? GROUP BY status`,
cutoff)
if err != nil {
return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err)
}
defer rows.Close()
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return FleetSummary{}, fmt.Errorf("store: fleet summary scan: %w", err)
}
fs.JobsLast24h += n
switch status {
case "succeeded":
fs.JobsLast24hSucceeded = n
case "failed":
fs.JobsLast24hFailed = n
case "cancelled":
fs.JobsLast24hCancelled = n
}
}
return fs, rows.Err()
}