4c108bb68a
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.
104 lines
4.4 KiB
HTML
104 lines
4.4 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>
|
|
|
|
<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}}
|