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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user