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.
424 lines
13 KiB
Go
424 lines
13 KiB
Go
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{}
|