diff --git a/internal/server/http/server.go b/internal/server/http/server.go
index a21aedd..66a1b3b 100644
--- a/internal/server/http/server.go
+++ b/internal/server/http/server.go
@@ -282,6 +282,12 @@ func (s *Server) routes(r chi.Router) {
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
// Live job log.
r.Get("/jobs/{id}", s.handleUIJobDetail)
+ // Restore wizard (P3-01/P3-02). Two GET variants land on the
+ // same handler; the second deep-links a chosen snapshot.
+ r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
+ r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
+ r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
+ r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
}
// Browser job-log stream (separate from /ws/agent so the auth
diff --git a/internal/server/http/ui_restore.go b/internal/server/http/ui_restore.go
new file mode 100644
index 0000000..6db8d4d
--- /dev/null
+++ b/internal/server/http/ui_restore.go
@@ -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 "" is not substituted
+// here — that happens at dispatch time.
+func defaultRestoreTargetDir() string {
+ return defaultRestoreTargetRoot() + "//"
+}
+
+// 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{}
diff --git a/internal/server/http/ui_restore_test.go b/internal/server/http/ui_restore_test.go
new file mode 100644
index 0000000..048b082
--- /dev/null
+++ b/internal/server/http/ui_restore_test.go
@@ -0,0 +1,354 @@
+// ui_restore_test.go — covers the restore wizard backend (P3-01).
+package http
+
+import (
+ "context"
+ "encoding/json"
+ stdhttp "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/api"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+// seedSnapshot creates a snapshot row directly via ReplaceHostSnapshots.
+// Returns the snapshot ID.
+func seedSnapshot(t *testing.T, st *store.Store, hostID, hostname string) string {
+ t.Helper()
+ id := strings.ReplaceAll(ulid.Make().String(), "-", "")
+ short := id[:8]
+ if err := st.ReplaceHostSnapshots(context.Background(), hostID, []store.Snapshot{{
+ ID: id, ShortID: short, Time: time.Now().UTC().Add(-2 * time.Hour),
+ Hostname: hostname, Paths: []string{"/etc"}, Tags: []string{"system-config"},
+ SizeBytes: 612 * 1024 * 1024, FileCount: 100,
+ }}, time.Now().UTC()); err != nil {
+ t.Fatalf("seed snapshot: %v", err)
+ }
+ return id
+}
+
+// TestRestoreWizardGetRendersStep1 verifies the snapshot picker is on
+// the page when no snapshot is pre-selected.
+func TestRestoreWizardGetRendersStep1(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, _ := enrolHostForUI(t, srv, st, "rstore-host-1")
+ _ = seedSnapshot(t, st, hostID, "rstore-host-1")
+ cookie := loginAsAdmin(t, st)
+
+ req, _ := stdhttp.NewRequest("GET", ts.URL+"/hosts/"+hostID+"/restore", nil)
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Fatalf("status: got %d, want 200", res.StatusCode)
+ }
+ body := readBody(t, res.Body)
+ if !strings.Contains(body, "Restore from snapshot") {
+ t.Errorf("expected wizard heading; body: %s", short(body))
+ }
+ if !strings.Contains(body, "Pick a snapshot first") &&
+ !strings.Contains(body, "Pick the point-in-time you want to restore from") {
+ t.Errorf("expected step-1 prompt")
+ }
+}
+
+// TestRestoreWizardGetWithSnapshotPreselected verifies the deep-link
+// path puts the snapshot summary card on the page.
+func TestRestoreWizardGetWithSnapshotPreselected(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, _ := enrolHostForUI(t, srv, st, "rstore-host-2")
+ sid := seedSnapshot(t, st, hostID, "rstore-host-2")
+ cookie := loginAsAdmin(t, st)
+
+ req, _ := stdhttp.NewRequest("GET",
+ ts.URL+"/hosts/"+hostID+"/snapshots/"+sid+"/restore", nil)
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Fatalf("status: got %d", res.StatusCode)
+ }
+ body := readBody(t, res.Body)
+ // The selected summary card should reference the snapshot's short ID.
+ if !strings.Contains(body, sid[:8]) {
+ t.Errorf("expected snapshot short id in body")
+ }
+ if !strings.Contains(body, "picked from") {
+ t.Errorf("expected 'picked from N snapshots' summary line")
+ }
+}
+
+// TestRestorePostRequiresSnapshot: form without snapshot_id re-renders
+// with an error.
+func TestRestorePostRequiresSnapshot(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, _ := enrolHostForUI(t, srv, st, "rstore-no-snap")
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{
+ "snapshot_id": {""},
+ "target_mode": {"new_dir"},
+ "paths": {"/etc/foo"},
+ }
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnprocessableEntity {
+ t.Fatalf("status: got %d, want 422", res.StatusCode)
+ }
+ body := readBody(t, res.Body)
+ if !strings.Contains(body, "Pick a snapshot") {
+ t.Errorf("expected 'Pick a snapshot' error in body")
+ }
+}
+
+// TestRestorePostRequiresPaths: form with snapshot but no paths is rejected.
+func TestRestorePostRequiresPaths(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, _ := enrolHostForUI(t, srv, st, "rstore-no-paths")
+ sid := seedSnapshot(t, st, hostID, "rstore-no-paths")
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{
+ "snapshot_id": {sid},
+ "target_mode": {"new_dir"},
+ }
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnprocessableEntity {
+ t.Fatalf("status: got %d, want 422", res.StatusCode)
+ }
+ body := readBody(t, res.Body)
+ if !strings.Contains(body, "at least one file") {
+ t.Errorf("expected paths-required error")
+ }
+}
+
+// TestRestorePostInPlaceRequiresHostnameMatch: in-place mode with the
+// wrong hostname typed re-renders + does not dispatch.
+func TestRestorePostInPlaceRequiresHostnameMatch(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, token := enrolHostForUI(t, srv, st, "rstore-inplace")
+ sid := seedSnapshot(t, st, hostID, "rstore-inplace")
+ c := agentDial(t, srv, ts, hostID, token)
+ sendHello(t, c, "rstore-inplace")
+ _ = drainUntil(t, c, api.MsgScheduleSet)
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{
+ "snapshot_id": {sid},
+ "target_mode": {"in_place"},
+ "paths": {"/etc/nginx/nginx.conf"},
+ "confirm_hostname": {"WRONG"},
+ }
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnprocessableEntity {
+ t.Fatalf("status: got %d, want 422", res.StatusCode)
+ }
+
+ // No restore command should arrive at the agent.
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ for {
+ mt, raw, rerr := c.Read(ctx)
+ if rerr != nil {
+ break
+ }
+ if mt == websocket.MessageText && strings.Contains(string(raw), `"command.run"`) &&
+ strings.Contains(string(raw), `"kind":"restore"`) {
+ t.Fatal("unexpected restore command.run after wrong-hostname rejection")
+ }
+ }
+}
+
+// TestRestorePostHappyPathDispatches: well-formed new-directory form
+// dispatches a JobRestore command.run with the expected payload + writes
+// an audit row + redirects.
+func TestRestorePostHappyPathDispatches(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, token := enrolHostForUI(t, srv, st, "rstore-happy")
+ sid := seedSnapshot(t, st, hostID, "rstore-happy")
+ c := agentDial(t, srv, ts, hostID, token)
+ sendHello(t, c, "rstore-happy")
+ _ = drainUntil(t, c, api.MsgScheduleSet)
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{
+ "snapshot_id": {sid},
+ "target_mode": {"new_dir"},
+ "paths": {"/etc/nginx/nginx.conf", "/etc/nginx/sites-available/alfa.conf"},
+ }
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("HX-Request", "true")
+ req.AddCookie(cookie)
+ // Don't follow redirects — we want to inspect the HX-Redirect header.
+ client := &stdhttp.Client{
+ CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
+ return stdhttp.ErrUseLastResponse
+ },
+ }
+ res, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusNoContent {
+ t.Fatalf("status: got %d, want 204", res.StatusCode)
+ }
+ if res.Header.Get("HX-Redirect") == "" {
+ t.Fatal("expected HX-Redirect header pointing at the live job page")
+ }
+
+ // Find the dispatched command.run on the agent socket.
+ deadline := time.Now().Add(2 * time.Second)
+ var got api.Envelope
+ for time.Now().Before(deadline) {
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ mt, raw, rerr := c.Read(ctx)
+ cancel()
+ if rerr != nil {
+ break
+ }
+ if mt != websocket.MessageText {
+ continue
+ }
+ if !strings.Contains(string(raw), `"command.run"`) || !strings.Contains(string(raw), `"kind":"restore"`) {
+ continue
+ }
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ break
+ }
+ if got.Type != api.MsgCommandRun {
+ t.Fatal("never received restore command.run")
+ }
+ var cp api.CommandRunPayload
+ if err := got.UnmarshalPayload(&cp); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if cp.Kind != api.JobRestore {
+ t.Fatalf("kind: got %q", cp.Kind)
+ }
+ if cp.Restore == nil {
+ t.Fatal("restore payload is nil")
+ }
+ if cp.Restore.SnapshotID != sid {
+ t.Fatalf("snapshot id: got %q want %q", cp.Restore.SnapshotID, sid)
+ }
+ if cp.Restore.InPlace {
+ t.Fatal("expected new-directory mode (in_place=false)")
+ }
+ if !strings.HasPrefix(cp.Restore.TargetDir, "/var/restic-restore/") {
+ t.Fatalf("target_dir: got %q, want prefix /var/restic-restore/", cp.Restore.TargetDir)
+ }
+ if len(cp.Restore.Paths) != 2 {
+ t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths))
+ }
+
+ // Audit row.
+ var n int
+ if err := st.DB().QueryRow(
+ `SELECT COUNT(*) FROM audit_log WHERE action = 'host.restore' AND target_id = ?`,
+ hostID).Scan(&n); err != nil {
+ t.Fatalf("audit count: %v", err)
+ }
+ if n != 1 {
+ t.Fatalf("audit rows: got %d, want 1", n)
+ }
+}
+
+// TestRestorePostOfflineHostRejected: agent not connected → 503 +
+// no command.run.
+func TestRestorePostOfflineHostRejected(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, _ := enrolHostForUI(t, srv, st, "rstore-offline")
+ sid := seedSnapshot(t, st, hostID, "rstore-offline")
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{
+ "snapshot_id": {sid},
+ "target_mode": {"new_dir"},
+ "paths": {"/etc/foo"},
+ }
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/restore", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusServiceUnavailable {
+ t.Fatalf("status: got %d, want 503", res.StatusCode)
+ }
+ _ = srv
+}
+
+// helpers --------------------------------------------------------------
+
+func readBody(t *testing.T, body interface{ Read(p []byte) (int, error) }) string {
+ t.Helper()
+ buf := make([]byte, 0, 16*1024)
+ tmp := make([]byte, 4096)
+ for {
+ n, err := body.Read(tmp)
+ if n > 0 {
+ buf = append(buf, tmp[:n]...)
+ }
+ if err != nil {
+ break
+ }
+ }
+ return string(buf)
+}
+
+func short(s string) string {
+ if len(s) > 400 {
+ return s[:400] + "…"
+ }
+ return s
+}
diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go
index 905fe7f..8c5e52b 100644
--- a/internal/server/ui/ui.go
+++ b/internal/server/ui/ui.go
@@ -92,6 +92,7 @@ func New() (*Renderer, error) {
"templates/partials/toast.html",
"templates/partials/awaiting_agent.html",
"templates/partials/host_chrome.html",
+ "templates/partials/tree_node.html",
}
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
diff --git a/internal/store/migrations/0012_jobs_restore_diff_kind.sql b/internal/store/migrations/0012_jobs_restore_diff_kind.sql
new file mode 100644
index 0000000..7c4673c
--- /dev/null
+++ b/internal/store/migrations/0012_jobs_restore_diff_kind.sql
@@ -0,0 +1,61 @@
+-- 0012_jobs_restore_diff_kind.sql
+--
+-- Add 'restore' and 'diff' to the jobs.kind CHECK constraint so the
+-- restore wizard (P3-01) and the snapshot-diff endpoint (P3-09) can
+-- persist their job rows. SQLite can't ALTER a CHECK in place, so we
+-- rebuild the table.
+--
+-- Rebuild safety: jobs has an inbound FK from job_logs (ON DELETE
+-- CASCADE) and from schedules.jobs is referenced via scheduled_id.
+-- CLAUDE.md flags DROP TABLE on a parent as risky under
+-- foreign_keys=ON; we mitigate two ways:
+--
+-- 1. Stash job_logs into a temp table BEFORE rebuilding jobs, then
+-- restore the rows after the rebuild settles. If a cascade
+-- misbehaves we can still recover.
+-- 2. Use the safe rebuild order from 0005: create jobs_new with the
+-- wider CHECK → copy data → DROP jobs → RENAME jobs_new TO jobs.
+-- Do NOT rename the original first (the dangling-FK trap that
+-- 0005's first draft hit and 0006 cleaned up).
+
+CREATE TEMPORARY TABLE _job_logs_backup AS
+ SELECT job_id, seq, ts, stream, payload FROM job_logs;
+
+CREATE TABLE jobs_new (
+ id TEXT PRIMARY KEY,
+ host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK (kind IN
+ ('backup','init','forget','prune','check','unlock','restore','diff')),
+ status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
+ scheduled_id TEXT REFERENCES schedules(id) ON DELETE SET NULL,
+ actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user','schedule','system')),
+ actor_id TEXT,
+ started_at TEXT,
+ finished_at TEXT,
+ exit_code INTEGER,
+ stats TEXT,
+ error TEXT,
+ created_at TEXT NOT NULL
+);
+
+INSERT INTO jobs_new
+ SELECT id, host_id, kind, status, scheduled_id, actor_kind, actor_id,
+ started_at, finished_at, exit_code, stats, error, created_at
+ FROM jobs;
+
+DROP TABLE jobs;
+
+ALTER TABLE jobs_new RENAME TO jobs;
+
+CREATE INDEX jobs_host_id ON jobs(host_id);
+CREATE INDEX jobs_status ON jobs(status);
+CREATE INDEX jobs_created_at ON jobs(created_at);
+
+-- Defensive: if cascade-on-DROP wiped job_logs (it shouldn't with the
+-- foreign_keys behaviour SQLite documents, but the codebase has hit
+-- "lost rows" before during rebuilds), restore from the temp backup.
+-- INSERT OR IGNORE so re-running is harmless.
+INSERT OR IGNORE INTO job_logs (job_id, seq, ts, stream, payload)
+ SELECT job_id, seq, ts, stream, payload FROM _job_logs_backup;
+
+DROP TABLE _job_logs_backup;
diff --git a/web/templates/pages/host_detail.html b/web/templates/pages/host_detail.html
index 1ccf63b..3b866fe 100644
--- a/web/templates/pages/host_detail.html
+++ b/web/templates/pages/host_detail.html
@@ -51,7 +51,7 @@
{{if eq $s.FileCount 0}}— {{else}}{{comma $s.FileCount}}{{end}}
{{end}}
@@ -76,6 +76,16 @@
+
+
Restore
+
+ Pick a snapshot, choose paths, dispatch. Live progress streams once the
+ agent starts.
+
+
Restore from snapshot…
+
+
Danger zone
diff --git a/web/templates/pages/host_restore.html b/web/templates/pages/host_restore.html
new file mode 100644
index 0000000..8a411b8
--- /dev/null
+++ b/web/templates/pages/host_restore.html
@@ -0,0 +1,332 @@
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "content"}}
+{{template "host_chrome" .}}
+{{$page := .Page}}
+{{$host := $page.Host}}
+
+
+
+
+
Restore from snapshot
+
+ Pick a snapshot, choose paths, decide where files go, then dispatch.
+ Live progress streams to a job page once you start.
+
+
+
+
+
+ {{if $page.Error}}
+
+ {{$page.Error}}
+
+ {{end}}
+
+
+
+
+{{/* Lightweight JS to drive the live tally + summary card. No HTMX
+ here; the tree HTML is HTMX-loaded but the running tally is just
+ reading the form state on click. */}}
+
+
+{{end}}
diff --git a/web/templates/pages/job_detail.html b/web/templates/pages/job_detail.html
index 8ba18af..47e2445 100644
--- a/web/templates/pages/job_detail.html
+++ b/web/templates/pages/job_detail.html
@@ -75,10 +75,10 @@
{{/* ---------- progress (running only) ---------- */}}
{{if $page.IsActive}}
-
+
-
-
—
+
+ —
@@ -86,6 +86,12 @@
+ {{if eq (printf "%s" $job.Kind) "restore"}}
+
+ Current
+ —
+
+ {{end}}
{{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 @@
`
${escapeHtml(p.payload)} `;
stream.appendChild(line);
if (autoScroll) container.scrollTop = container.scrollHeight;
+ maybeUpdateCurrent(p);
}
ws.onmessage = (ev) => {
diff --git a/web/templates/partials/tree_node.html b/web/templates/partials/tree_node.html
new file mode 100644
index 0000000..593b619
--- /dev/null
+++ b/web/templates/partials/tree_node.html
@@ -0,0 +1,39 @@
+{{define "tree_node"}}
+{{$page := .Page}}
+{{if $page.Error}}
+
error: {{$page.Error}}
+{{else}}
+ {{/* parent path heading + collapse marker */}}
+
+ {{$page.Path}}
+ {{if not $page.Children}}
+ empty directory
+ {{end}}
+
+ {{range $page.Children}}
+
+ {{if .IsDir}}
+ ▸
+ {{else}}
+ ·
+ {{end}}
+
+
+
+ {{.Name}}{{if .IsDir}}/{{end}}
+
+ {{if not .IsDir}}{{if .Size}}{{bytes .Size}}{{else}}—{{end}}{{end}}
+
+ {{if .IsDir}}
+
+ {{end}}
+ {{end}}
+{{end}}
+{{end}}