6e47efc146
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.
40 lines
1.9 KiB
HTML
40 lines
1.9 KiB
HTML
{{define "tree_node"}}
|
|
{{$page := .Page}}
|
|
{{if $page.Error}}
|
|
<div class="px-3 py-2 mono text-[12px] text-bad">error: {{$page.Error}}</div>
|
|
{{else}}
|
|
{{/* parent path heading + collapse marker */}}
|
|
<div class="flex items-center gap-2 px-3 py-1.5 text-[12px] text-ink-mute border-b border-line-soft">
|
|
<span class="mono text-ink-mid">{{$page.Path}}</span>
|
|
{{if not $page.Children}}
|
|
<span class="text-ink-fade ml-auto mono text-[11px]">empty directory</span>
|
|
{{end}}
|
|
</div>
|
|
{{range $page.Children}}
|
|
<div class="grid items-center gap-2 px-3 py-[5px] mono text-[12.5px] border-b border-line-soft last:border-b-0"
|
|
style="grid-template-columns: 14px 16px auto 1fr auto;">
|
|
{{if .IsDir}}
|
|
<button type="button"
|
|
class="text-ink-mute text-[10px] cursor-pointer"
|
|
hx-get="/hosts/{{$page.HostID}}/restore/tree?snapshot={{$page.SnapshotID}}&path={{.Path}}"
|
|
hx-target="next .tree-children"
|
|
hx-swap="innerHTML"
|
|
onclick="this.parentElement.querySelector('.tree-children').classList.toggle('hidden'); this.textContent = this.parentElement.querySelector('.tree-children').classList.contains('hidden') ? '▸' : '▾';">▸</button>
|
|
{{else}}
|
|
<span class="text-ink-fade text-center">·</span>
|
|
{{end}}
|
|
<label class="cursor-pointer flex items-center justify-center">
|
|
<input type="checkbox" name="paths" value="{{.Path}}"
|
|
class="w-[13px] h-[13px] cursor-pointer" />
|
|
</label>
|
|
<span class="{{if .IsDir}}text-ink{{else}}text-ink-mid{{end}}">{{.Name}}{{if .IsDir}}/{{end}}</span>
|
|
<span></span>
|
|
<span class="text-[11px] text-ink-fade">{{if not .IsDir}}{{if .Size}}{{bytes .Size}}{{else}}—{{end}}{{end}}</span>
|
|
</div>
|
|
{{if .IsDir}}
|
|
<div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
|
|
{{end}}
|
|
{{end}}
|
|
{{end}}
|
|
{{end}}
|