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
+17
View File
@@ -512,6 +512,14 @@ type hostChromeData struct {
InitStatus string
InitAt *time.Time // started_at if non-nil else created_at
InitJobID string
// Latest 'restore' job — surfaced as a small line below the
// init-status one so the operator has at-a-glance visibility into
// recent destructive activity. Empty status means no restore has
// ever run on this host.
RestoreStatus string
RestoreAt *time.Time
RestoreJobID string
}
// loadHostChrome fetches the per-tab counts that every host-detail tab
@@ -542,6 +550,15 @@ func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, cru
}
d.InitAt = &t
}
if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "restore"); err == nil && j != nil {
d.RestoreStatus = j.Status
d.RestoreJobID = j.ID
t := j.CreatedAt
if j.StartedAt != nil {
t = *j.StartedAt
}
d.RestoreAt = &t
}
return d
}