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>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -161,6 +161,7 @@
|
||||
.host-row {
|
||||
display: grid; align-items: center;
|
||||
grid-template-columns: 24px 1.4fr 0.95fr 1.5fr 0.75fr 0.7fr 0.7fr 1.1fr 92px;
|
||||
column-gap: 18px;
|
||||
padding: 11px 16px; font-size: 13px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
|
||||
@@ -1,24 +1,98 @@
|
||||
{{define "title"}}Dashboard · restic-manager{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pt-14 pb-24">
|
||||
<div class="max-w-[1280px] mx-auto px-8">
|
||||
|
||||
<!-- placeholder hero — real fleet summary lands with P1-24.
|
||||
This view exists today so the chrome + Tailwind + base layout
|
||||
can be verified in the browser end-to-end. -->
|
||||
<div class="empty-state">
|
||||
<h1 class="text-lg font-medium tracking-[-0.005em]">Dashboard</h1>
|
||||
<p class="text-pretty text-ink-mid mt-3 mx-auto max-w-[520px] text-[13px] leading-[1.65]">
|
||||
Base layout + Tailwind build wired through (P1-23 / P1-28). The fleet
|
||||
summary, host table and recent activity strip land with
|
||||
<span class="mono text-ink">P1-24</span>; until then this screen exists
|
||||
to prove the chrome renders.
|
||||
</p>
|
||||
<div class="mt-7 flex items-center justify-center gap-2">
|
||||
<a href="/hosts/new" class="btn btn-primary btn-lg">+ Add your first host</a>
|
||||
<a href="/" class="btn">Reload</a>
|
||||
{{$page := .Page}}
|
||||
{{if eq $page.HostCount 0}}
|
||||
|
||||
{{/* ---------- empty state ---------- */}}
|
||||
<div class="pt-14 pb-24">
|
||||
<div class="empty-state">
|
||||
<h1 class="text-lg font-medium tracking-[-0.005em]">No hosts yet.</h1>
|
||||
<p class="text-pretty text-ink-mid mt-3 mx-auto max-w-[520px] text-[13px] leading-[1.65]">
|
||||
<span class="mono text-ink">restic-manager</span> tracks backups across a fleet —
|
||||
but there’s nothing to track until you enrol your first host. Mint a token
|
||||
from <span class="mono text-ink">+ Add host</span>, paste the install command on
|
||||
a Linux box, and the host will appear here within seconds.
|
||||
</p>
|
||||
<div class="mt-7 flex items-center justify-center gap-2">
|
||||
<a href="/hosts/new" class="btn btn-primary btn-lg">+ Add your first host</a>
|
||||
<a href="/" class="btn">Reload</a>
|
||||
</div>
|
||||
<div class="mt-8 text-[12px] text-ink-fade">
|
||||
Prerequisite: <span class="mono text-ink-mute">restic</span> ≥ 0.16 already installed on the target host.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
|
||||
{{/* ---------- fleet summary ---------- */}}
|
||||
<div class="grid grid-cols-12 gap-6 pt-7 pb-2">
|
||||
<div class="col-span-3">
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2">Fleet</div>
|
||||
<div class="mono text-[28px] font-medium tracking-[-0.02em]">{{$page.Summary.TotalHosts}} <span class="text-ink-mute text-[13px] font-normal">hosts</span></div>
|
||||
<div class="flex items-center gap-3 mt-2.5 text-xs">
|
||||
<span class="flex items-center gap-1.5"><span class="dot dot-online"></span><span class="mono text-ink-mid">{{$page.Summary.HostsOnline}}</span><span class="text-ink-mute">online</span></span>
|
||||
<span class="flex items-center gap-1.5"><span class="dot dot-degraded"></span><span class="mono text-ink-mid">{{$page.Summary.HostsDegraded}}</span><span class="text-ink-mute">degraded</span></span>
|
||||
<span class="flex items-center gap-1.5"><span class="dot dot-offline"></span><span class="mono text-ink-mid">{{$page.Summary.HostsOffline}}</span><span class="text-ink-mute">offline</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2">Backed up</div>
|
||||
<div class="mono text-[28px] font-medium tracking-[-0.02em]">{{bytes $page.Summary.RepoBytesTotal}}</div>
|
||||
<div class="text-xs text-ink-mute mt-2.5"><span class="mono text-ink-mid">{{comma $page.Summary.SnapshotsTotal}}</span> snapshots total</div>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2">Last 24h</div>
|
||||
<div class="mono text-[28px] font-medium tracking-[-0.02em]">{{$page.Summary.JobsLast24h}} <span class="text-ink-mute text-[13px] font-normal">jobs</span></div>
|
||||
<div class="text-xs mt-2.5">
|
||||
<span class="mono text-ok">{{$page.Summary.JobsLast24hSucceeded}}</span> <span class="text-ink-mute">succeeded</span>
|
||||
{{if gt $page.Summary.JobsLast24hFailed 0}} · <span class="mono text-bad">{{$page.Summary.JobsLast24hFailed}}</span> <span class="text-ink-mute">failed</span>{{end}}
|
||||
{{if gt $page.Summary.JobsLast24hCancelled 0}} · <span class="mono text-ink-mid">{{$page.Summary.JobsLast24hCancelled}}</span> <span class="text-ink-mute">cancelled</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2">Open alerts</div>
|
||||
{{if eq $page.Summary.OpenAlerts 0}}
|
||||
<div class="mono text-[28px] font-medium tracking-[-0.02em] text-ink-mid">0 <span class="text-ink-mute text-[13px] font-normal">unresolved</span></div>
|
||||
<div class="text-xs text-ink-mute mt-2.5">all clear</div>
|
||||
{{else}}
|
||||
<div class="mono text-[28px] font-medium tracking-[-0.02em] text-bad">{{$page.Summary.OpenAlerts}} <span class="text-ink-mute text-[13px] font-normal">unresolved</span></div>
|
||||
<div class="text-xs text-ink-mute mt-2.5"><a href="/alerts" class="underline underline-offset-4 decoration-line">review →</a></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---------- hosts table ---------- */}}
|
||||
<div class="pt-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-[13px] font-semibold tracking-[0.01em]">Hosts</h2>
|
||||
<div class="text-xs text-ink-fade">{{$page.HostCount}} of {{$page.HostCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel rounded-[7px] overflow-hidden">
|
||||
|
||||
<div class="host-row head hairline">
|
||||
<div></div>
|
||||
<div>Host</div>
|
||||
<div>OS · arch</div>
|
||||
<div>Last backup</div>
|
||||
<div class="text-right">Repo size</div>
|
||||
<div class="text-right">Snapshots</div>
|
||||
<div>Alerts</div>
|
||||
<div>Tags</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{{range $page.Hosts}}{{template "host_row" .}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{{define "host_row"}}
|
||||
<div class="row-hover host-row hairline {{.Status}}{{if eq (deref .LastBackupStatus) "failed"}} failed{{end}}">
|
||||
<div>
|
||||
{{- if eq .Status "online" -}}
|
||||
<span class="dot dot-online{{if .CurrentJobID}} pulse{{end}}"></span>
|
||||
{{- else if eq .Status "degraded" -}}
|
||||
<span class="dot dot-degraded"></span>
|
||||
{{- else if eq .Status "offline" -}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{- else -}}
|
||||
<span class="dot dot-failed"></span>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}} font-medium">{{.Name}}</div>
|
||||
<div class="mono text-ink-mid text-[12px]">{{.OS}}/{{.Arch}}</div>
|
||||
<div class="text-xs text-ink-mid">
|
||||
{{- if .CurrentJobID -}}
|
||||
<span class="text-accent">backup running…</span><br>
|
||||
<span class="mono text-ink-fade">started {{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq (deref .LastBackupStatus) "succeeded" -}}
|
||||
<span class="text-ok">succeeded</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq (deref .LastBackupStatus) "failed" -}}
|
||||
<span class="text-bad font-medium">failed</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq (deref .LastBackupStatus) "cancelled" -}}
|
||||
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime .LastBackupAt}}</span>
|
||||
{{- else if eq .Status "offline" -}}
|
||||
<span class="text-ink-mute">last seen <span class="mono">{{relTime .LastSeenAt}}</span></span>
|
||||
{{- else -}}
|
||||
<span class="text-ink-fade italic">never run</span>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="text-right mono {{if eq .Status "offline"}}text-ink-mid{{else}}text-ink{{end}}">{{bytes .RepoSizeBytes}}</div>
|
||||
<div class="text-right mono {{if eq .Status "offline"}}text-ink-mute{{else}}text-ink-mid{{end}}">
|
||||
{{- if eq .SnapshotCount 0 -}}
|
||||
<span class="text-ink-fade">—</span>
|
||||
{{- else -}}
|
||||
{{comma .SnapshotCount}}
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="text-right mono {{if gt .OpenAlertCount 0}}text-bad font-medium{{else}}text-ink-mute{{end}}">
|
||||
{{- if eq .OpenAlertCount 0 -}}—{{- else -}}{{.OpenAlertCount}}{{- end -}}
|
||||
</div>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{{- range .Tags -}}
|
||||
<span class="tag">{{.}}</span>
|
||||
{{- end -}}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{{- if eq .Status "offline" -}}
|
||||
<span class="mono text-xs text-ink-fade">offline</span>
|
||||
{{- else if .CurrentJobID -}}
|
||||
<a href="/hosts/{{.ID}}" class="btn btn-ghost">View →</a>
|
||||
{{- else if eq (deref .LastBackupStatus) "failed" -}}
|
||||
<button class="btn"
|
||||
hx-post="/hosts/{{.ID}}/run-backup"
|
||||
hx-swap="none"
|
||||
hx-disabled-elt="this">Retry</button>
|
||||
{{- else if eq .SnapshotCount 0 -}}
|
||||
<button class="btn"
|
||||
hx-post="/hosts/{{.ID}}/run-backup"
|
||||
hx-swap="none"
|
||||
hx-disabled-elt="this">Run first</button>
|
||||
{{- else -}}
|
||||
<button class="btn"
|
||||
hx-post="/hosts/{{.ID}}/run-backup"
|
||||
hx-swap="none"
|
||||
hx-disabled-elt="this">Run now</button>
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user