Files
restic-manager/web/templates/pages/dashboard.html
T
steve 523ac4137a
CI / Test (store) (pull_request) Successful in 6s
CI / Lint (pull_request) Successful in 20s
CI / Build (windows/amd64) (pull_request) Successful in 10s
CI / Test (rest) (pull_request) Successful in 41s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 3m8s
e2e / Playwright vs docker-compose (pull_request) Failing after 3m28s
ui: show pending-hosts panel even when fleet is otherwise empty
The dashboard's empty-state ("No hosts yet.") was gated on
HostCount == 0 alone, which hid the pending-hosts panel — and
the inline accept form — for the most common first-run scenario:
operator just installed an agent that announced, the fleet has
zero accepted hosts, and the only thing the operator needs to do
is review fingerprint + click Accept.

Tighten the gate so the empty state only shows when there are
truly zero hosts and zero pending announces. With a pending
host, fall through to the regular dashboard layout so the
approval queue is visible and actionable.

Caught by the e2e enrol-via-announce smoke test (now unblocked
on PR #23).
2026-05-08 21:47:31 +01:00

231 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}Dashboard · restic-manager{{end}}
{{define "content"}}
<div class="max-w-[1280px] mx-auto px-8">
{{$page := .Page}}
{{template "crit_banner" .Page}}
{{if and (eq $page.HostCount 0) (eq (len $page.PendingHosts) 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 theres 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>
{{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-behind hero tile (P6-18) ---------- */}}
{{if gt $page.UpdatesBehind 0}}
<div class="pt-4">
<a href="?updates=behind" class="hero-tile hero-tile--amber" style="display:inline-flex;">
<span class="hero-num">{{$page.UpdatesBehind}}</span>
<span class="hero-label">{{if eq $page.UpdatesBehind 1}}host behind{{else}}hosts behind{{end}} · review →</span>
</a>
</div>
{{end}}
{{/* ---------- Pending hosts (announce-and-approve queue) ---------- */}}
{{if gt (len $page.PendingHosts) 0}}
<div class="pt-6">
<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] text-warn">Pending hosts</h2>
<div class="text-xs text-ink-fade">{{len $page.PendingHosts}} waiting for approval</div>
</div>
</div>
<div class="panel rounded-[7px] overflow-hidden"
style="border-color: color-mix(in oklch, var(--warn), transparent 70%);">
{{range $i, $ph := $page.PendingHosts}}
<div class="p-4 {{if not (eq $i 0)}}hairline{{end}}">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="mono text-ink font-medium">{{$ph.Hostname}}</span>
<span class="mono text-[11px] text-ink-fade">{{$ph.OS}}/{{$ph.Arch}}</span>
<span class="mono text-[11px] text-ink-fade">agent {{$ph.AgentVersion}}</span>
<span class="mono text-[11px] text-ink-fade">restic {{$ph.ResticVersion}}</span>
</div>
<div class="mt-2 mono text-[12px] text-ink-mid select-all break-all"
style="font-family: var(--font-mono); padding: 6px 8px; background: var(--panel-hi); border-radius: 4px;">
{{$ph.Fingerprint}}
</div>
<div class="text-[11px] text-ink-fade mt-2">
from {{$ph.AnnouncedFromIP}} · {{relTime $ph.FirstSeenAt}}
· expires {{relTime $ph.ExpiresAt}}
</div>
</div>
<form method="post" action="/api/pending-hosts/{{$ph.ID}}/accept"
class="flex flex-col gap-2 flex-none" style="width: 320px;"
onsubmit="return confirm('Accept host &quot;{{$ph.Hostname}}&quot; (fingerprint {{$ph.Fingerprint}})? Make sure this matches what the install script printed.');">
<input type="text" name="repo_url" required placeholder="rest:http://…"
class="input mono" style="height: 28px; padding: 0 8px; font-size: 12px;">
<input type="text" name="repo_username" placeholder="repo username (optional)"
class="input mono" style="height: 28px; padding: 0 8px; font-size: 12px;">
<input type="password" name="repo_password" required placeholder="repo password"
class="input mono" style="height: 28px; padding: 0 8px; font-size: 12px;">
<div class="flex gap-2">
<button type="submit" class="btn btn-primary flex-1">Accept</button>
<button type="button" class="btn btn-danger flex-1"
hx-post="/api/pending-hosts/{{$ph.ID}}/reject"
hx-confirm="Reject pending host '{{$ph.Hostname}}'?"
hx-on::after-request="window.location.reload()">Reject</button>
</div>
</form>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* ---------- hosts table ---------- */}}
{{$f := $page.Filter}}
{{$sortURL := $page.SortURL}}
<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.ShownCount}} of {{$page.HostCount}}</div>
</div>
<label style="display: inline-flex; align-items: center; gap: 5px; cursor: pointer; font-size: 10px;"
class="text-ink-fade" title="auto-refresh every 5s">
<input type="checkbox" id="dashboard-live-toggle" checked
onchange="localStorage.setItem('rm-dashboard-live', this.checked ? 'on' : 'off'); document.getElementById('dashboard-live-dot').style.opacity = this.checked ? '1' : '0.3';"
style="width: 11px; height: 11px; cursor: pointer; margin: 0;" />
<span>live</span>
<span id="dashboard-live-dot" class="text-accent"></span>
</label>
</div>
{{/* Filter row (NS-04): GET /, every input is a hidden field
for the filters not currently being edited so submit
merges rather than clobbers state. */}}
<form method="get" action="/" class="flex items-center gap-2 mb-3 text-[11.5px] flex-wrap">
<input type="text" name="q" value="{{$f.Search}}" placeholder="search hostname…"
class="field mono"
style="padding: 6px 10px; font-size: 11.5px; width: 220px;">
<select name="status" class="field"
style="padding: 5px 8px; font-size: 11.5px; width: auto;"
onchange="this.form.submit()">
<option value="" {{if eq $f.Status ""}}selected{{end}}>any status</option>
<option value="online" {{if eq $f.Status "online"}}selected{{end}}>online</option>
<option value="offline" {{if eq $f.Status "offline"}}selected{{end}}>offline</option>
<option value="never_seen" {{if eq $f.Status "never_seen"}}selected{{end}}>never seen</option>
</select>
<select name="repo_status" class="field"
style="padding: 5px 8px; font-size: 11.5px; width: auto;"
onchange="this.form.submit()">
<option value="" {{if eq $f.RepoStatus ""}}selected{{end}}>any repo state</option>
<option value="ready" {{if eq $f.RepoStatus "ready"}}selected{{end}}>ready</option>
<option value="init_failed" {{if eq $f.RepoStatus "init_failed"}}selected{{end}}>init failed</option>
<option value="unknown" {{if eq $f.RepoStatus "unknown"}}selected{{end}}>unknown</option>
</select>
{{if $f.Tag}}<input type="hidden" name="tag" value="{{$f.Tag}}">{{end}}
{{if ne $f.Sort "name"}}<input type="hidden" name="sort" value="{{$f.Sort}}">{{end}}
{{if eq $f.Dir "desc"}}<input type="hidden" name="dir" value="desc">{{end}}
<button type="submit" class="btn btn-sm">Apply</button>
{{if or $f.Search $f.Status $f.RepoStatus}}
<a href="/{{if $f.Tag}}?tag={{$f.Tag}}{{end}}" class="text-ink-fade text-[11.5px] mono ml-1">clear</a>
{{end}}
</form>
{{/* Tag chip-row — only renders when at least one tag exists in
the fleet. Active tag is highlighted; clicking the active
tag clears the filter. The "All" pill is shown in the active
state when no tag filter is set. */}}
{{if $page.KnownTags}}
<div class="flex items-center gap-1.5 flex-wrap mb-3 text-[11.5px]">
<span class="text-ink-fade mr-1">tag</span>
<a href="/" class="tag {{if eq $page.ActiveTag ""}}tag-active{{end}}">All</a>
{{range $page.KnownTags}}
{{$t := .}}
<a href="/?tag={{$t}}" class="tag {{if eq $page.ActiveTag $t}}tag-active{{end}}">{{$t}}</a>
{{end}}
</div>
{{end}}
{{/* Live-poll wrapper (NS-04, mirrors the alerts pattern). hx-get
refetches with the current filter pinned; hx-select grabs only
this same div from the response so the surrounding chrome
doesn't flash. The toggle persists in localStorage so a
refreshed tab honours the operator's previous choice. */}}
<div id="hosts-table" class="panel rounded-[7px] overflow-hidden"
hx-get="{{$page.RefreshURL}}"
hx-trigger="every 5s [document.visibilityState==='visible' && localStorage.getItem('rm-dashboard-live')!=='off']"
hx-select="#hosts-table"
hx-swap="outerHTML">
<div class="host-row head hairline">
<div></div>
<div><a href="{{index $sortURL "name"}}" class="text-ink-mid hover:text-ink">Host{{if eq $f.Sort "name"}} {{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 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-ink-mid text-right">Alerts</div>
<div class="text-ink-mid">Tags</div>
<div></div>
</div>
{{range $page.Hosts}}{{template "host_row" .}}{{end}}
</div>
</div>
{{end}}
</div>
{{end}}