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}}
- + Restore →
{{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. +
+
+
+ Cancel +
+
+ + {{if $page.Error}} +
+ {{$page.Error}} +
+ {{end}} + +
+ + {{/* ============ STEP 1 — snapshot picker ============ */}} +
+
+
+ {{if $page.Selected}} + + {{else}} + 1 + {{end}} +
+
Snapshot
+
Pick the point-in-time you want to restore from.
+
+
+ step 1 of 4 +
+
+ {{if $page.Selected}} + {{/* selected summary card */}} +
+ {{$page.Selected.ShortID}} +
+
{{$page.Selected.Time.Format "2006-01-02 15:04 MST"}} ·{{relTime $page.Selected.Time}}
+
+ {{range $page.Selected.Tags}}{{.}}{{end}} + paths: + {{range $i, $p := $page.Selected.Paths}}{{if $i}}, {{end}}{{$p}}{{end}} + {{if $page.Selected.SizeBytes}} · {{bytes $page.Selected.SizeBytes}}{{end}} +
+
+ picked from {{len $page.Snapshots}} snapshots + Change +
+ + {{else}} + {{/* full picker table */}} +
+
+
Time
+
Tag
+
Paths
+
Size
+
Snapshot ID
+
+
+ {{if not $page.Snapshots}} +
No snapshots yet. Run a backup first.
+ {{end}} + {{range $page.Snapshots}} + +
{{relTime .Time}}
+
{{range .Tags}}{{.}}{{end}}
+
+ {{range $i, $p := .Paths}}{{if $i}}, {{end}}{{$p}}{{end}} +
+
{{if .SizeBytes}}{{bytes .SizeBytes}}{{else}}—{{end}}
+
{{.ShortID}}
+
+
+ {{end}} +
+ {{end}} +
+
+ + {{/* ============ STEP 2 — paths (tree browser) ============ */}} +
+
+
+ 2 +
+
Paths
+
Tick files and directories to restore. Folders restore recursively.
+
+
+ step 2 of 4 +
+
+ {{if $page.Selected}} +
+ {{/* The tree browser is server-rendered as a single root node; HTMX expand-on-click loads children. */}} +
+
loading…
+
+
+
+ 0 files selected + · + tick a file or directory above +
+ {{else}} +
Pick a snapshot above to load its paths.
+ {{end}} +
+
+ + {{/* ============ STEP 3 — target ============ */}} +
+
+
+ 3 +
+
Target
+
Where should the files land? Defaults to a fresh, isolated directory.
+
+
+ step 3 of 4 +
+
+
+ + + +
+
+
+ + {{/* ============ STEP 4 — confirm ============ */}} +
+
+
+ 4 +
+
Confirm & start
+
Final review. Logs and progress will stream live.
+
+
+ step 4 of 4 +
+
+
A summary will appear here once you've made your selections.
+
+
+ + {{/* sticky-style action bar */}} +
+
+ Audit row host.restore will be written on dispatch. +
+
+ Back + +
+
+
+
+ +{{/* 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}}