Files
restic-manager/web/templates/pages/host_detail.html
T
steve c417b5e9ab P3-09 + P3-X3: snapshot diff + recent-restores line
P3-09 — snapshot diff dispatcher.
- POST /api/hosts/{id}/snapshots/diff (and the unprefixed HTMX-form
  variant) takes {snapshot_a, snapshot_b}, validates both belong to
  the host (long id / short id / prefix match), checks the agent is
  online, mints a JobDiff, ships command.run with DiffPayload, writes
  a host.snapshot_diff audit row, returns HX-Redirect to the live
  job page (or JSON {job_id, job_url} for REST callers).
- Two-snapshot guard: POSTing diff(a,a) returns 422.
- UI: small panel on the host_detail right rail (visible when the
  host has 2+ snapshots) with two short-id inputs and a Diff button.
  Output renders on the standard live job page where the operator
  reads the per-line diff text directly.

P3-X3 — recent-restores line.
- hostChromeData grows RestoreStatus / RestoreAt / RestoreJobID
  populated via store.LatestJobByKind(host_id, 'restore') (already
  exists, used by the init line).
- host_chrome.html renders a small line below the existing init-status
  one with status-coloured copy + a link to the job log. Hidden when
  no restore has ever run on this host.

Tests:
- diff_test covers happy path (correct DiffPayload + HX-Redirect),
  same-id rejection (422), unknown-id rejection (422). Adds a
  seedTwoSnapshots helper since ReplaceHostSnapshots is atomic-swap
  (calling seedSnapshot twice would only leave the second).

Restage block (CLAUDE.md) deferred to the end of the restore phase.
2026-05-04 15:38:28 +01:00

123 lines
5.3 KiB
HTML

{{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">
{{/* ---------- snapshots tab ---------- */}}
<div class="grid grid-cols-12 gap-6 pt-6 items-start">
<div class="col-span-9">
<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]">Snapshots</h2>
<div class="text-xs text-ink-fade">showing {{$page.SnapshotsShown}} of {{comma $host.SnapshotCount}}</div>
</div>
</div>
<div class="panel rounded-[7px] overflow-hidden">
{{if eq (len $page.Snapshots) 0}}
<div class="empty-state" style="border: none; background: var(--panel);">
<h3 class="text-base font-medium tracking-[-0.005em]">No snapshots yet.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[440px] leading-[1.65]">
Once a backup completes, the agent will refresh this list automatically.
</p>
<div class="mt-5">
<a href="/hosts/{{$host.ID}}/sources" class="btn">Open Sources →</a>
</div>
</div>
{{else}}
<div class="hairline grid items-baseline px-4 py-2.5 text-[11px] text-ink-fade uppercase tracking-[0.08em]"
style="grid-template-columns: 0.8fr 1fr 2fr 0.7fr 0.7fr 0.7fr; column-gap: 18px;">
<div>Snapshot id</div>
<div>Time</div>
<div>Paths</div>
<div class="text-right">Size</div>
<div class="text-right">Files</div>
<div></div>
</div>
{{range $i, $s := $page.Snapshots}}
<div class="grid items-center px-4 py-2.5 text-[13px] hairline"
style="grid-template-columns: 0.8fr 1fr 2fr 0.7fr 0.7fr 0.7fr; column-gap: 18px;">
<div class="mono text-ink font-medium">{{$s.ShortID}}</div>
<div class="mono text-ink-mid text-[12px]">{{absTime $s.Time}}</div>
<div class="mono text-ink-mid text-[12px] truncate" title="{{joinDot $s.Paths}}">{{joinDot $s.Paths}}</div>
<div class="text-right mono text-ink">{{bytes $s.SizeBytes}}</div>
<div class="text-right mono text-ink-mid">
{{if eq $s.FileCount 0}}<span class="text-ink-fade"></span>{{else}}{{comma $s.FileCount}}{{end}}
</div>
<div class="text-right">
<a href="/hosts/{{$host.ID}}/snapshots/{{$s.ID}}/restore" class="btn">Restore →</a>
</div>
</div>
{{end}}
{{end}}
</div>
{{if eq $host.SnapshotCount 0}}
{{else if gt $host.SnapshotCount $page.SnapshotsShown}}
<div class="text-xs text-ink-mute mt-3 px-1">showing the {{$page.SnapshotsShown}} most-recent snapshots — full pagination lands later</div>
{{end}}
</div>
{{/* ---------- right rail ---------- */}}
<aside class="col-span-3 flex flex-col gap-4">
<div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Run-now</div>
<p class="text-[12px] text-ink-mute leading-[1.55] mb-2">
Run-now lives on individual source groups now —
<a href="/hosts/{{$host.ID}}/sources" class="underline">open Sources →</a>
</p>
</div>
<div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Restore</div>
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
Pick a snapshot, choose paths, dispatch. Live progress streams once the
agent starts.
</p>
<a href="/hosts/{{$host.ID}}/restore"
class="btn btn-block">Restore from snapshot…</a>
</div>
{{if gt $host.SnapshotCount 1}}
<div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Compare snapshots</div>
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
Diff two snapshots to see what changed. Output streams to a live
job page like a regular run.
</p>
<form method="post" action="/hosts/{{$host.ID}}/snapshots/diff"
hx-post="/hosts/{{$host.ID}}/snapshots/diff" hx-swap="none"
class="space-y-2">
<input type="text" name="snapshot_a" placeholder="snapshot A id"
class="field mono text-[11.5px]" />
<input type="text" name="snapshot_b" placeholder="snapshot B id"
class="field mono text-[11.5px]" />
<button type="submit" class="btn btn-block">Diff →</button>
</form>
</div>
{{end}}
<div class="panel rounded-[7px] px-4 py-3.5">
<div class="text-[11px] text-bad uppercase tracking-[0.1em] font-semibold mb-2.5">Danger zone</div>
<p class="text-pretty text-[12px] text-ink-mute leading-[1.55] mb-3">
Removes the host record. The repo data on the rest-server is left intact —
you delete that yourself.
</p>
<button class="btn btn-danger w-full justify-center" disabled title="lands later in Phase 1">Remove host…</button>
</div>
</aside>
</div>
</div>
{{end}}