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:
@@ -0,0 +1,423 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// ui_restore.go — restore wizard backend (P3-01).
|
||||
//
|
||||
// GET /hosts/{id}/restore wizard step 1 (snapshot picker)
|
||||
// GET /hosts/{id}/snapshots/{sid}/restore wizard with snapshot pre-selected
|
||||
// GET /hosts/{id}/restore/tree HTMX partial: one tree node + children
|
||||
// POST /hosts/{id}/restore dispatch the restore job
|
||||
|
||||
// hostRestorePage is the model for the wizard template.
|
||||
type hostRestorePage struct {
|
||||
hostChromeData
|
||||
|
||||
// Snapshot picker rows; rendered by the template into the step-1
|
||||
// table. Limited to most-recent N (the operator can refine on
|
||||
// snapshot ID if they need an older one — out of scope for v1).
|
||||
Snapshots []store.Snapshot
|
||||
|
||||
// Selected is non-nil iff a snapshot has been chosen — either via
|
||||
// the deep-link path /hosts/{id}/snapshots/{sid}/restore or by a
|
||||
// previous form submission that the wizard re-rendered.
|
||||
Selected *store.Snapshot
|
||||
|
||||
// Default target dir — surfaced in the step-3 radio card.
|
||||
DefaultTargetDir string
|
||||
|
||||
// Online mirrors Hub.Connected so the dispatch button can be
|
||||
// disabled at render time when the agent is offline.
|
||||
Online bool
|
||||
|
||||
// Error is shown as a banner above the wizard. Re-render-friendly:
|
||||
// the operator's snapshot/path/target choices survive the round-trip.
|
||||
Error string
|
||||
|
||||
// Form fields preserved on validation re-render. The template
|
||||
// reads these to pre-tick checkboxes etc; the names match the
|
||||
// POST form keys.
|
||||
FormPaths []string // "/etc/nginx/sites-available/alfa.conf"
|
||||
FormInPlace bool
|
||||
FormTargetDir string
|
||||
FormConfirmHN string // typed-confirm input value
|
||||
}
|
||||
|
||||
// handleUIRestoreGet renders the wizard. URL variants:
|
||||
// - /hosts/{id}/restore — step 1 = pick snapshot
|
||||
// - /hosts/{id}/snapshots/{sid}/restore — snapshot pre-selected
|
||||
func (s *Server) handleUIRestoreGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("ui restore: get host", "host_id", hostID, "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
page := hostRestorePage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"),
|
||||
DefaultTargetDir: defaultRestoreTargetDir(),
|
||||
Online: s.deps.Hub.Connected(host.ID),
|
||||
}
|
||||
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
slog.Error("ui restore: list snapshots", "host_id", hostID, "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(snaps) > 100 {
|
||||
snaps = snaps[:100]
|
||||
}
|
||||
page.Snapshots = snaps
|
||||
|
||||
// Snapshot deep-link variant — if the URL carries a sid, prefill it.
|
||||
if sid := chi.URLParam(r, "sid"); sid != "" {
|
||||
for i := range snaps {
|
||||
if snaps[i].ID == sid || snaps[i].ShortID == sid {
|
||||
p := snaps[i]
|
||||
page.Selected = &p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view := s.baseView(u)
|
||||
view.Title = "Restore · " + host.Name
|
||||
view.Page = page
|
||||
if err := s.deps.UI.Render(w, "host_restore", view); err != nil {
|
||||
slog.Error("ui restore: render", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIRestorePost validates the form and dispatches the restore
|
||||
// job. On validation error re-renders the wizard with the error
|
||||
// banner + the operator's input intact.
|
||||
func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad form", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
snapshotID := strings.TrimSpace(r.PostForm.Get("snapshot_id"))
|
||||
paths := r.PostForm["paths"] // multiple checkbox values
|
||||
inPlace := r.PostForm.Get("target_mode") == "in_place"
|
||||
targetDir := strings.TrimSpace(r.PostForm.Get("target_dir"))
|
||||
confirmHN := strings.TrimSpace(r.PostForm.Get("confirm_hostname"))
|
||||
|
||||
rerender := func(errMsg string, status int) {
|
||||
page := hostRestorePage{
|
||||
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"),
|
||||
DefaultTargetDir: defaultRestoreTargetDir(),
|
||||
Online: s.deps.Hub.Connected(host.ID),
|
||||
Error: errMsg,
|
||||
FormPaths: paths,
|
||||
FormInPlace: inPlace,
|
||||
FormTargetDir: targetDir,
|
||||
FormConfirmHN: confirmHN,
|
||||
}
|
||||
snaps, _ := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
|
||||
if len(snaps) > 100 {
|
||||
snaps = snaps[:100]
|
||||
}
|
||||
page.Snapshots = snaps
|
||||
for i := range snaps {
|
||||
if snaps[i].ID == snapshotID || snaps[i].ShortID == snapshotID {
|
||||
ss := snaps[i]
|
||||
page.Selected = &ss
|
||||
break
|
||||
}
|
||||
}
|
||||
view := s.baseView(u)
|
||||
view.Title = "Restore · " + host.Name
|
||||
view.Page = page
|
||||
w.WriteHeader(status)
|
||||
_ = s.deps.UI.Render(w, "host_restore", view)
|
||||
}
|
||||
|
||||
if snapshotID == "" {
|
||||
rerender("Pick a snapshot first.", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
cleanPaths := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
rerender("Paths must be absolute (start with /).", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
cleanPaths = append(cleanPaths, p)
|
||||
}
|
||||
if len(cleanPaths) == 0 {
|
||||
rerender("Pick at least one file or directory to restore.", stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if inPlace {
|
||||
if confirmHN != host.Name {
|
||||
rerender("Type the host name exactly to confirm an in-place (overwrite) restore.",
|
||||
stdhttp.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// New-directory mode: server picks the path so the operator
|
||||
// can't escape /var/restic-restore. Operator-supplied
|
||||
// target_dir is intentionally ignored.
|
||||
targetDir = ""
|
||||
}
|
||||
|
||||
if !s.deps.Hub.Connected(host.ID) {
|
||||
rerender("Agent is offline. Try again when it reconnects.",
|
||||
stdhttp.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a new job id up-front so we can substitute it into the
|
||||
// new-directory target path. The dispatch helper will use this
|
||||
// same id (mint=now → reuse via dispatchJobWithPayload's
|
||||
// signature requires the id, so do it here and pass on).
|
||||
jobID := ulid.Make().String()
|
||||
finalTarget := ""
|
||||
if !inPlace {
|
||||
finalTarget = path.Join(defaultRestoreTargetRoot(), jobID)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := s.deps.Store.CreateJob(r.Context(), store.Job{
|
||||
ID: jobID,
|
||||
HostID: host.ID,
|
||||
Kind: string(api.JobRestore),
|
||||
ActorKind: "user",
|
||||
ActorID: &u.ID,
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
slog.Error("ui restore: create job", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
payload := api.CommandRunPayload{
|
||||
JobID: jobID,
|
||||
Kind: api.JobRestore,
|
||||
Restore: &api.RestorePayload{
|
||||
SnapshotID: snapshotID,
|
||||
Paths: cleanPaths,
|
||||
InPlace: inPlace,
|
||||
TargetDir: finalTarget,
|
||||
},
|
||||
}
|
||||
env, err := api.Marshal(api.MsgCommandRun, jobID, payload)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil {
|
||||
slog.Warn("ui restore: dispatch failed", "err", err)
|
||||
rerender("Couldn't deliver the restore command (agent went offline).",
|
||||
stdhttp.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &u.ID,
|
||||
Actor: "user",
|
||||
Action: "host.restore",
|
||||
TargetKind: ptr("host"),
|
||||
TargetID: &host.ID,
|
||||
TS: now,
|
||||
})
|
||||
|
||||
// HTMX redirect (or vanilla redirect) to the live job log.
|
||||
jobURL := "/jobs/" + jobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", jobURL)
|
||||
w.WriteHeader(stdhttp.StatusNoContent)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, jobURL, stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// hostRestoreTreePage is the data shape for the tree-node HTMX partial.
|
||||
type hostRestoreTreePage struct {
|
||||
HostID string
|
||||
SnapshotID string
|
||||
Path string
|
||||
Children []treeChildView
|
||||
Error string
|
||||
}
|
||||
|
||||
// treeChildView is one row of the tree (a direct child of Path).
|
||||
type treeChildView struct {
|
||||
Name string
|
||||
Type string // dir | file | symlink
|
||||
Path string // full path, used in the checkbox value
|
||||
Size int64
|
||||
IsDir bool
|
||||
}
|
||||
|
||||
// handleUIRestoreTree is the HTMX-served partial that loads one
|
||||
// directory's children. Called when the operator clicks an expand
|
||||
// chevron in the wizard's tree browser. Caches via fetchTreeWithCache.
|
||||
func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
snapshotID := strings.TrimSpace(q.Get("snapshot"))
|
||||
pathArg := strings.TrimSpace(q.Get("path"))
|
||||
if pathArg == "" {
|
||||
pathArg = "/"
|
||||
}
|
||||
if snapshotID == "" {
|
||||
stdhttp.Error(w, "snapshot required", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.deps.Hub.Connected(host.ID) {
|
||||
// Render the partial with an error message rather than 503ing
|
||||
// — the wizard renders the error inline next to the failed node.
|
||||
page := hostRestoreTreePage{
|
||||
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
||||
Error: "agent offline",
|
||||
}
|
||||
view := s.baseView(u)
|
||||
view.Page = page
|
||||
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := sessionIDFromCookie(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := s.fetchTreeWithCache(ctx, sessionID, host.ID, snapshotID, pathArg)
|
||||
if err != nil {
|
||||
page := hostRestoreTreePage{
|
||||
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
||||
Error: err.Error(),
|
||||
}
|
||||
view := s.baseView(u)
|
||||
view.Page = page
|
||||
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
|
||||
return
|
||||
}
|
||||
if result.Error != "" {
|
||||
page := hostRestoreTreePage{
|
||||
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
||||
Error: result.Error,
|
||||
}
|
||||
view := s.baseView(u)
|
||||
view.Page = page
|
||||
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
|
||||
return
|
||||
}
|
||||
|
||||
children := make([]treeChildView, 0, len(result.Entries))
|
||||
for _, e := range result.Entries {
|
||||
full := joinTreePath(pathArg, e.Name)
|
||||
children = append(children, treeChildView{
|
||||
Name: e.Name, Type: e.Type, Path: full,
|
||||
Size: e.Size,
|
||||
IsDir: e.Type == "dir",
|
||||
})
|
||||
}
|
||||
// Stable order: dirs first, then files, alphabetically.
|
||||
sort.SliceStable(children, func(i, j int) bool {
|
||||
if children[i].IsDir != children[j].IsDir {
|
||||
return children[i].IsDir
|
||||
}
|
||||
return children[i].Name < children[j].Name
|
||||
})
|
||||
|
||||
page := hostRestoreTreePage{
|
||||
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
||||
Children: children,
|
||||
}
|
||||
view := s.baseView(u)
|
||||
view.Page = page
|
||||
if err := s.deps.UI.RenderPartial(w, "tree_node", view); err != nil {
|
||||
slog.Warn("ui restore tree: render partial", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRestoreTargetRoot is the parent of the per-job restore
|
||||
// directory. Chosen on a per-host basis would be nicer but the agent
|
||||
// is the one that actually creates it, and /var/restic-restore is
|
||||
// fine for Linux hosts (the agent's systemd unit runs as root).
|
||||
func defaultRestoreTargetRoot() string {
|
||||
return "/var/restic-restore"
|
||||
}
|
||||
|
||||
// defaultRestoreTargetDir surfaces the placeholder path shown on the
|
||||
// step-3 New-directory radio card. The "<job-id>" is not substituted
|
||||
// here — that happens at dispatch time.
|
||||
func defaultRestoreTargetDir() string {
|
||||
return defaultRestoreTargetRoot() + "/<job-id>/"
|
||||
}
|
||||
|
||||
// sessionIDFromCookie returns the operator's session cookie value,
|
||||
// used as the cache key scope for the tree-list cache. Unauthenticated
|
||||
// requests don't reach this point, so an empty cookie value would
|
||||
// only happen if requireUIUser is bypassed in tests — fall back to
|
||||
// the request remote addr for those cases.
|
||||
func sessionIDFromCookie(r *stdhttp.Request) string {
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// joinTreePath combines a directory path and a child name into an
|
||||
// absolute snapshot-relative path, normalising any duplicate slashes.
|
||||
func joinTreePath(dir, name string) string {
|
||||
if dir == "" || dir == "/" {
|
||||
return "/" + name
|
||||
}
|
||||
return strings.TrimRight(dir, "/") + "/" + name
|
||||
}
|
||||
|
||||
// satisfy unused-import if compile order shifts.
|
||||
var _ = ui.User{}
|
||||
Reference in New Issue
Block a user