P3-01/02/03: restore wizard backend + templates + restore-shaped job page

End-to-end wizard from /hosts/{id}/restore (or per-snapshot deep link
/hosts/{id}/snapshots/{sid}/restore) → tree-browse → dispatch →
restore-shaped live job page.

Backend (internal/server/http/ui_restore.go):
- GET handlers render the four-step wizard against the wireframe shape
  in docs/superpowers/specs/2026-05-04-p3-restore-design.md.
- HTMX tree partial endpoint hits fetchTreeWithCache (P3-X2) so each
  directory expansion is a sub-second cached lookup after the first
  miss.
- POST validates: snapshot_id non-empty, ≥1 absolute path, in-place
  mode requires confirm_hostname == host name, agent online. On error
  re-renders the wizard with the operator's input intact. Happy path
  mints a job_id, computes the new-directory target as
  /var/restic-restore/<job-id>/ (operator can't escape the prefix —
  server picks it), creates the job row, ships command.run with
  kind=restore + RestorePayload, writes a host.restore audit row,
  returns HX-Redirect (or 303) to the live job page.

Templates:
- host_restore.html: single-page progressively-enabled wizard matching
  _diag/p3-restore-wizard wireframe. Form-state-driven JS computes a
  running tally of selected paths and the step-4 confirm summary
  client-side; the server re-renders on validation failure with form
  fields preserved.
- partials/tree_node.html: recursive HTMX-served tree fragment.
- Top-level Restore button on host_detail right rail + per-snapshot
  Restore action on snapshot rows replace the previous P3-stub.

Restore-shaped job page (job_detail.html):
- Progress widget rendered as a panel rather than a bare strip when
  the job is active.
- Current-file display under the bar, updated from log.stream stdout
  lines that look like absolute paths. Hidden for non-restore kinds.

Migration 0012:
- Add restore + diff to the jobs.kind CHECK. Rebuild required (SQLite
  can't ALTER CHECK in place); follows the safe pattern from 0005.
  Defensive: stash job_logs into a temp table before the rebuild and
  INSERT OR IGNORE back afterwards so even if SQLite cascades on
  DROP TABLE jobs the log history survives.

Tests:
- ui_restore_test covers GET step-1 render, GET pre-selected snapshot
  summary card, POST missing snapshot, POST missing paths, POST
  in-place wrong-hostname rejection (no command.run leaks to the
  agent), POST happy path (HX-Redirect + correct payload + audit
  row), POST against offline host returns 503.

Restage block (CLAUDE.md) deferred to the end of the restore phase.
This commit is contained in:
2026-05-04 15:34:29 +01:00
parent f5e3bca6a2
commit 4c108bb68a
9 changed files with 1249 additions and 4 deletions
+22 -3
View File
@@ -75,10 +75,10 @@
{{/* ---------- progress (running only) ---------- */}}
{{if $page.IsActive}}
<div class="mt-7" id="progress-block">
<div class="mt-7 panel rounded-[8px] p-[18px]" id="progress-block">
<div class="flex items-center justify-between mb-2.5">
<div class="flex items-center gap-3 text-sm">
<span class="mono text-ink font-medium" id="progress-pct"></span>
<div class="flex items-center gap-3.5 text-sm">
<span class="mono text-ink font-medium" id="progress-pct" style="font-size: 18px;"></span>
<span class="text-ink-mute" id="progress-bytes"></span>
</div>
<div class="text-sm text-ink-mute" id="progress-rate"></div>
@@ -86,6 +86,12 @@
<div class="progress-track">
<div class="progress-fill" id="progress-fill" style="width: 0%;"></div>
</div>
{{if eq (printf "%s" $job.Kind) "restore"}}
<div class="mt-3 text-[12px] text-ink-mute" id="restore-current-block">
<span class="text-ink-fade uppercase tracking-[0.08em] text-[10.5px]">Current</span>
<span class="mono text-ink-mid ml-2.5" id="restore-current-file"></span>
</div>
{{end}}
</div>
{{end}}
@@ -194,6 +200,18 @@
return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i];
}
const currentFileEl = document.getElementById('restore-current-file');
function maybeUpdateCurrent(p) {
// Restore-specific: surface the most recent stdout path in the
// "Current" slot. Restic restore --json prints per-file lines on
// stdout (no JSON wrapper) so any line starting with "/" is a
// good candidate.
if (!currentFileEl || p.stream !== 'stdout') return;
const v = (p.payload || '').trim();
if (v.startsWith('/') && v.length < 400) {
currentFileEl.textContent = v;
}
}
function appendLine(p) {
// Drop the "awaiting" placeholder once real lines arrive.
if (stream.children.length === 1 && stream.firstElementChild.textContent.includes('awaiting agent')) {
@@ -208,6 +226,7 @@
`<span class="log-stream-${p.stream}">${escapeHtml(p.payload)}</span>`;
stream.appendChild(line);
if (autoScroll) container.scrollTop = container.scrollHeight;
maybeUpdateCurrent(p);
}
ws.onmessage = (ev) => {