Files
restic-manager/web/templates/pages/dashboard.html
T
steve ccaccd840a ui: dashboard hosts-behind tile + filter
- Add ?updates=behind query filter and the matching dashboardFilter
  field; round-trips through encode/parse.
- Compute UpdatesBehind on the dashboard view-model (online + version
  trailing the server) and surface as an amber hero tile that links
  to the filtered list.
- Test exercise covering the new filter case.
2026-05-06 22:20:54 +01:00

230 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 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 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-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>Tags</div>
<div></div>
</div>
{{range $page.Hosts}}{{template "host_row" .}}{{end}}
</div>
</div>
{{end}}
</div>
{{end}}