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.
This commit is contained in:
2026-05-04 15:38:28 +01:00
parent 4c108bb68a
commit c417b5e9ab
7 changed files with 372 additions and 0 deletions
+19
View File
@@ -86,6 +86,25 @@
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">
+20
View File
@@ -121,6 +121,26 @@
</div>
{{end}}
{{/* ---------- latest restore line (P3-X3) ---------- */}}
{{if $page.RestoreStatus}}
<div class="text-[11.5px] text-ink-mute mt-1 leading-[1.5]">
{{if eq $page.RestoreStatus "succeeded"}}
last restore · <span class="text-ok">succeeded</span> <span class="mono text-ink-mid">{{relTime $page.RestoreAt}}</span> ·
<a href="/jobs/{{$page.RestoreJobID}}" class="link mono">job log →</a>
{{else if eq $page.RestoreStatus "failed"}}
last restore · <span class="text-bad font-medium">failed</span> <span class="mono text-ink-mid">{{relTime $page.RestoreAt}}</span> ·
<a href="/jobs/{{$page.RestoreJobID}}" class="link mono">job log →</a>
{{else if eq $page.RestoreStatus "running"}}
<span class="text-accent">restore running…</span> · <a href="/jobs/{{$page.RestoreJobID}}" class="link mono">live log →</a>
{{else if eq $page.RestoreStatus "cancelled"}}
last restore · <span class="text-warn">cancelled</span> <span class="mono text-ink-mid">{{relTime $page.RestoreAt}}</span> ·
<a href="/jobs/{{$page.RestoreJobID}}" class="link mono">job log →</a>
{{else if eq $page.RestoreStatus "queued"}}
<span class="text-ink-fade">restore queued</span> · <a href="/jobs/{{$page.RestoreJobID}}" class="link mono">job {{$page.RestoreJobID}}</a>
{{end}}
</div>
{{end}}
{{/* ---------- secondary tabs ---------- */}}
<div class="flex items-end mt-1.5">
<a class="sub-tab {{if eq $page.SubTab "snapshots"}}active{{end}}" href="/hosts/{{$host.ID}}">Snapshots <span class="mono text-ink-fade text-[11px] ml-1">{{comma $host.SnapshotCount}}</span></a>