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:
@@ -282,6 +282,12 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
|
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
|
||||||
// Live job log.
|
// Live job log.
|
||||||
r.Get("/jobs/{id}", s.handleUIJobDetail)
|
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
|
// Browser job-log stream (separate from /ws/agent so the auth
|
||||||
|
|||||||
@@ -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{}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ func New() (*Renderer, error) {
|
|||||||
"templates/partials/toast.html",
|
"templates/partials/toast.html",
|
||||||
"templates/partials/awaiting_agent.html",
|
"templates/partials/awaiting_agent.html",
|
||||||
"templates/partials/host_chrome.html",
|
"templates/partials/host_chrome.html",
|
||||||
|
"templates/partials/tree_node.html",
|
||||||
}
|
}
|
||||||
|
|
||||||
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
|
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
{{if eq $s.FileCount 0}}<span class="text-ink-fade">—</span>{{else}}{{comma $s.FileCount}}{{end}}
|
{{if eq $s.FileCount 0}}<span class="text-ink-fade">—</span>{{else}}{{comma $s.FileCount}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<button class="btn btn-ghost" disabled title="restore wizard lands in P3">Restore →</button>
|
<a href="/hosts/{{$host.ID}}/snapshots/{{$s.ID}}/restore" class="btn">Restore →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -76,6 +76,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel rounded-[7px] px-4 py-3.5">
|
||||||
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Restore</div>
|
||||||
|
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
|
||||||
|
Pick a snapshot, choose paths, dispatch. Live progress streams once the
|
||||||
|
agent starts.
|
||||||
|
</p>
|
||||||
|
<a href="/hosts/{{$host.ID}}/restore"
|
||||||
|
class="btn btn-block">Restore from snapshot…</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel rounded-[7px] px-4 py-3.5">
|
<div class="panel rounded-[7px] px-4 py-3.5">
|
||||||
<div class="text-[11px] text-bad uppercase tracking-[0.1em] font-semibold mb-2.5">Danger zone</div>
|
<div class="text-[11px] text-bad uppercase tracking-[0.1em] font-semibold mb-2.5">Danger zone</div>
|
||||||
<p class="text-pretty text-[12px] text-ink-mute leading-[1.55] mb-3">
|
<p class="text-pretty text-[12px] text-ink-mute leading-[1.55] mb-3">
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
{{define "title"}}{{.Title}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "host_chrome" .}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
{{$host := $page.Host}}
|
||||||
|
<div class="max-w-[1280px] mx-auto px-8 pt-6 pb-14">
|
||||||
|
|
||||||
|
<div class="flex items-baseline justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-[19px] font-medium tracking-[-0.005em]">Restore from snapshot</h2>
|
||||||
|
<div class="text-[12.5px] text-ink-mute mt-1">
|
||||||
|
Pick a snapshot, choose paths, decide where files go, then dispatch.
|
||||||
|
Live progress streams to a job page once you start.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/hosts/{{$host.ID}}" class="btn">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $page.Error}}
|
||||||
|
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
||||||
|
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
||||||
|
{{$page.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/hosts/{{$host.ID}}/restore" id="restore-form" class="space-y-4">
|
||||||
|
|
||||||
|
{{/* ============ STEP 1 — snapshot picker ============ */}}
|
||||||
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden">
|
||||||
|
<header class="flex items-center justify-between px-[18px] py-[14px] border-b border-line-soft"
|
||||||
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{if $page.Selected}}
|
||||||
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
||||||
|
style="background: color-mix(in oklch, var(--ok), transparent 86%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 60%);">✓</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
||||||
|
style="background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);">1</span>
|
||||||
|
{{end}}
|
||||||
|
<div>
|
||||||
|
<div class="text-[14px] font-medium">Snapshot</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-0.5">Pick the point-in-time you want to restore from.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="mono text-[11px] text-ink-fade">step 1 of 4</span>
|
||||||
|
</header>
|
||||||
|
<div class="p-[18px]">
|
||||||
|
{{if $page.Selected}}
|
||||||
|
{{/* selected summary card */}}
|
||||||
|
<div class="grid items-center gap-4 px-3.5 py-3 rounded-[6px] bg-bg border border-line-soft"
|
||||||
|
style="grid-template-columns: auto 1fr auto auto;">
|
||||||
|
<span class="mono text-[12px] text-accent">{{$page.Selected.ShortID}}</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-[13px] text-ink">{{$page.Selected.Time.Format "2006-01-02 15:04 MST"}} <span class="text-ink-fade mx-2">·</span><span class="text-ink-mute">{{relTime $page.Selected.Time}}</span></div>
|
||||||
|
<div class="mt-1 text-[12px] text-ink-mute">
|
||||||
|
{{range $page.Selected.Tags}}<span class="tag mr-1.5">{{.}}</span>{{end}}
|
||||||
|
paths:
|
||||||
|
{{range $i, $p := $page.Selected.Paths}}{{if $i}}, {{end}}<span class="mono text-ink-mid">{{$p}}</span>{{end}}
|
||||||
|
{{if $page.Selected.SizeBytes}} · {{bytes $page.Selected.SizeBytes}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-ink-fade text-[12px]">picked from {{len $page.Snapshots}} snapshots</span>
|
||||||
|
<a href="/hosts/{{$host.ID}}/restore" class="btn">Change</a>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="snapshot_id" value="{{$page.Selected.ID}}" />
|
||||||
|
{{else}}
|
||||||
|
{{/* full picker table */}}
|
||||||
|
<div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden">
|
||||||
|
<div class="snap-row head">
|
||||||
|
<div>Time</div>
|
||||||
|
<div>Tag</div>
|
||||||
|
<div>Paths</div>
|
||||||
|
<div>Size</div>
|
||||||
|
<div>Snapshot ID</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{{if not $page.Snapshots}}
|
||||||
|
<div class="px-4 py-8 text-center text-ink-mute text-[13px]">No snapshots yet. Run a backup first.</div>
|
||||||
|
{{end}}
|
||||||
|
{{range $page.Snapshots}}
|
||||||
|
<a href="/hosts/{{$host.ID}}/snapshots/{{.ID}}/restore" class="snap-row" style="text-decoration: none; color: inherit;">
|
||||||
|
<div class="mono text-ink-mid">{{relTime .Time}}</div>
|
||||||
|
<div>{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</div>
|
||||||
|
<div class="text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
{{range $i, $p := .Paths}}{{if $i}}, {{end}}<span class="mono text-ink-mid">{{$p}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="mono text-ink-mid">{{if .SizeBytes}}{{bytes .SizeBytes}}{{else}}—{{end}}</div>
|
||||||
|
<div class="mono text-ink-mid">{{.ShortID}}</div>
|
||||||
|
<div></div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ============ STEP 2 — paths (tree browser) ============ */}}
|
||||||
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden {{if not $page.Selected}}opacity-40 pointer-events-none{{end}}">
|
||||||
|
<header class="flex items-center justify-between px-[18px] py-[14px] border-b border-line-soft"
|
||||||
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
||||||
|
style="{{if $page.Selected}}background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);{{else}}background: var(--bg); color: var(--ink-mute); border: 1px solid var(--line);{{end}}">2</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-[14px] font-medium">Paths</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-0.5">Tick files and directories to restore. Folders restore recursively.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="mono text-[11px] text-ink-fade">step 2 of 4</span>
|
||||||
|
</header>
|
||||||
|
<div class="p-[18px]">
|
||||||
|
{{if $page.Selected}}
|
||||||
|
<div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden p-2">
|
||||||
|
{{/* The tree browser is server-rendered as a single root node; HTMX expand-on-click loads children. */}}
|
||||||
|
<div hx-get="/hosts/{{$host.ID}}/restore/tree?snapshot={{$page.Selected.ID}}&path=/"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 px-3.5 py-2.5 rounded-[6px] text-[12.5px]"
|
||||||
|
style="border: 1px solid color-mix(in oklch, var(--accent), transparent 70%); background: color-mix(in oklch, var(--accent), transparent 92%);">
|
||||||
|
<span class="text-accent" id="tally-count">0 files selected</span>
|
||||||
|
<span class="text-ink-fade mx-2">·</span>
|
||||||
|
<span class="text-ink-mute mono" id="tally-paths">tick a file or directory above</span>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="text-ink-mute text-[13px]">Pick a snapshot above to load its paths.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ============ STEP 3 — target ============ */}}
|
||||||
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden {{if not $page.Selected}}opacity-40 pointer-events-none{{end}}">
|
||||||
|
<header class="flex items-center justify-between px-[18px] py-[14px] border-b border-line-soft"
|
||||||
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
||||||
|
style="background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);">3</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-[14px] font-medium">Target</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-0.5">Where should the files land? Defaults to a fresh, isolated directory.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="mono text-[11px] text-ink-fade">step 3 of 4</span>
|
||||||
|
</header>
|
||||||
|
<div class="p-[18px]">
|
||||||
|
<div class="grid grid-cols-2 gap-3.5">
|
||||||
|
<label class="block rounded-[7px] p-4 cursor-pointer transition border target-card-new"
|
||||||
|
id="target-new-card"
|
||||||
|
style="border-color: color-mix(in oklch, var(--accent), transparent 50%); background: color-mix(in oklch, var(--accent), transparent 95%);">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<input type="radio" name="target_mode" value="new_dir" class="mt-1" {{if not $page.FormInPlace}}checked{{end}} />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-[14px] font-medium text-ink">New directory</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
|
||||||
|
Files restore into a fresh path on the host. Original files untouched.
|
||||||
|
Restored as the agent user (no ownership preservation) so you can <span class="mono">cp</span> them out without sudo.
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 px-3 py-[9px] rounded-[5px] mono text-[12px] text-ink flex items-center gap-2.5"
|
||||||
|
style="background: var(--bg); border: 1px solid var(--line-soft);">
|
||||||
|
<span class="text-ink-fade">→</span>
|
||||||
|
<span>{{$page.DefaultTargetDir}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11.5px] text-ink-fade mt-1.5">Final job-id slug substituted on dispatch.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block rounded-[7px] p-4 cursor-pointer transition border target-card-inplace"
|
||||||
|
id="target-inplace-card"
|
||||||
|
style="border-color: color-mix(in oklch, var(--bad), transparent 70%); background: color-mix(in oklch, var(--bad), transparent 96%);">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<input type="radio" name="target_mode" value="in_place" class="mt-1" {{if $page.FormInPlace}}checked{{end}} />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-[14px] font-medium">
|
||||||
|
<span class="text-bad">In place</span>
|
||||||
|
<span class="text-ink-mute font-normal">— overwrite original paths</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
|
||||||
|
Files replace whatever is at their original paths.
|
||||||
|
Original ownership and permissions are preserved.
|
||||||
|
<span class="text-bad">Destructive — cannot be undone.</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 px-3 py-3 rounded-[5px]"
|
||||||
|
style="background: color-mix(in oklch, var(--bad), transparent 92%); border: 1px solid color-mix(in oklch, var(--bad), transparent 60%);">
|
||||||
|
<div class="text-[11px] text-bad uppercase tracking-[0.08em] font-medium">Confirm host name</div>
|
||||||
|
<div class="text-[11.5px] text-ink-mute mt-1 leading-[1.55]">
|
||||||
|
Type <span class="mono text-ink">{{$host.Name}}</span> to enable this option.
|
||||||
|
</div>
|
||||||
|
<input type="text" name="confirm_hostname" class="field mono mt-2"
|
||||||
|
placeholder="{{$host.Name}}"
|
||||||
|
value="{{$page.FormConfirmHN}}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ============ STEP 4 — confirm ============ */}}
|
||||||
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden {{if not $page.Selected}}opacity-40 pointer-events-none{{end}}">
|
||||||
|
<header class="flex items-center justify-between px-[18px] py-[14px]"
|
||||||
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
||||||
|
style="background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);">4</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-[14px] font-medium">Confirm & start</div>
|
||||||
|
<div class="text-[12px] text-ink-mute mt-0.5">Final review. Logs and progress will stream live.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="mono text-[11px] text-ink-fade">step 4 of 4</span>
|
||||||
|
</header>
|
||||||
|
<div class="px-[18px] pb-[18px]" id="confirm-summary">
|
||||||
|
<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you've made your selections.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* sticky-style action bar */}}
|
||||||
|
<div class="rounded-[8px] border border-line-soft px-[18px] py-[14px] flex items-center justify-between"
|
||||||
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
||||||
|
<div class="text-[12.5px] text-ink-mute">
|
||||||
|
Audit row <span class="mono text-ink-mid">host.restore</span> will be written on dispatch.
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<a href="/hosts/{{$host.ID}}" class="btn">Back</a>
|
||||||
|
<button type="submit" id="dispatch-btn" class="btn btn-primary btn-lg" {{if not $page.Online}}disabled title="agent is offline"{{end}}>
|
||||||
|
Start restore →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* 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. */}}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const form = document.getElementById('restore-form');
|
||||||
|
if (!form) return;
|
||||||
|
const tallyCount = document.getElementById('tally-count');
|
||||||
|
const tallyPaths = document.getElementById('tally-paths');
|
||||||
|
const dispatchBtn = document.getElementById('dispatch-btn');
|
||||||
|
const summary = document.getElementById('confirm-summary');
|
||||||
|
const inplaceRadio = document.querySelector('input[name="target_mode"][value="in_place"]');
|
||||||
|
const newRadio = document.querySelector('input[name="target_mode"][value="new_dir"]');
|
||||||
|
const newCard = document.getElementById('target-new-card');
|
||||||
|
const inplaceCard = document.getElementById('target-inplace-card');
|
||||||
|
const confirmInput = document.querySelector('input[name="confirm_hostname"]');
|
||||||
|
const hostName = {{$host.Name | js}};
|
||||||
|
const defaultTarget = {{$page.DefaultTargetDir | js}};
|
||||||
|
const selectedSnapID = {{if $page.Selected}}{{$page.Selected.ShortID | js}}{{else}}""{{end}};
|
||||||
|
const selectedSnapTime = {{if $page.Selected}}{{$page.Selected.Time.Format "2006-01-02 15:04 MST" | js}}{{else}}""{{end}};
|
||||||
|
|
||||||
|
function getCheckedPaths() {
|
||||||
|
return Array.from(form.querySelectorAll('input[name="paths"]:checked')).map(i => i.value);
|
||||||
|
}
|
||||||
|
function recompute() {
|
||||||
|
const paths = getCheckedPaths();
|
||||||
|
const count = paths.length;
|
||||||
|
if (tallyCount) tallyCount.textContent = count + ' file' + (count === 1 ? '' : 's') + ' selected';
|
||||||
|
if (tallyPaths) {
|
||||||
|
tallyPaths.textContent = count === 0 ? 'tick a file or directory above'
|
||||||
|
: paths.slice(0, 4).join(' · ') + (count > 4 ? ' …' : '');
|
||||||
|
}
|
||||||
|
// Card emphasis on radio change
|
||||||
|
if (newCard && inplaceCard && inplaceRadio && newRadio) {
|
||||||
|
const isInPlace = inplaceRadio.checked;
|
||||||
|
newCard.style.borderColor = isInPlace ? 'var(--line-soft)' : 'color-mix(in oklch, var(--accent), transparent 50%)';
|
||||||
|
newCard.style.background = isInPlace ? 'var(--bg)' : 'color-mix(in oklch, var(--accent), transparent 95%)';
|
||||||
|
inplaceCard.style.borderColor = isInPlace ? 'color-mix(in oklch, var(--bad), transparent 35%)' : 'color-mix(in oklch, var(--bad), transparent 70%)';
|
||||||
|
inplaceCard.style.background = isInPlace ? 'color-mix(in oklch, var(--bad), transparent 90%)' : 'color-mix(in oklch, var(--bad), transparent 96%)';
|
||||||
|
}
|
||||||
|
// Dispatch button state
|
||||||
|
if (dispatchBtn) {
|
||||||
|
const inPlace = inplaceRadio && inplaceRadio.checked;
|
||||||
|
const okConfirm = !inPlace || (confirmInput && confirmInput.value.trim() === hostName);
|
||||||
|
const enabled = count > 0 && okConfirm;
|
||||||
|
dispatchBtn.disabled = !enabled || !{{if $page.Online}}true{{else}}false{{end}};
|
||||||
|
dispatchBtn.textContent = inPlace ? 'Start restore (overwrite) →' : 'Start restore →';
|
||||||
|
if (inPlace) dispatchBtn.classList.add('btn-danger'); else dispatchBtn.classList.remove('btn-danger');
|
||||||
|
}
|
||||||
|
// Summary card
|
||||||
|
if (summary) {
|
||||||
|
if (count === 0) {
|
||||||
|
summary.innerHTML = '<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you\'ve made your selections.</div>';
|
||||||
|
} else {
|
||||||
|
const inPlace = inplaceRadio && inplaceRadio.checked;
|
||||||
|
const targetLine = inPlace
|
||||||
|
? '<span class="text-bad">in place · originals will be overwritten</span>'
|
||||||
|
: '<span class="text-ink">New directory</span> <span class="text-ink-fade mx-2">·</span> <span class="mono text-ink-mid">' + defaultTarget + '</span>';
|
||||||
|
const ownLine = inPlace
|
||||||
|
? 'preserved (uid/gid/mode/mtime)'
|
||||||
|
: 'Restored as agent user · <span class="mono">--no-ownership</span>';
|
||||||
|
const pathLines = paths.slice(0, 12).map(p => '<div>' + p + '</div>').join('');
|
||||||
|
const more = paths.length > 12 ? ('<div class="text-ink-fade">… and ' + (paths.length - 12) + ' more</div>') : '';
|
||||||
|
summary.innerHTML = `
|
||||||
|
<div class="rounded-[6px] border border-line-soft p-3.5 bg-bg">
|
||||||
|
<div class="grid gap-y-2.5" style="grid-template-columns: 140px 1fr; column-gap: 18px; font-size: 13px;">
|
||||||
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Source</span>
|
||||||
|
<div>snapshot <span class="mono text-accent">${selectedSnapID}</span> · <span class="text-ink-mid">${selectedSnapTime}</span></div>
|
||||||
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Paths</span>
|
||||||
|
<div>
|
||||||
|
<span class="text-ink">${count} file${count === 1 ? '' : 's'}</span>
|
||||||
|
<div class="mono text-[11.5px] text-ink-mute mt-1.5 leading-[1.7]">${pathLines}${more}</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Target</span>
|
||||||
|
<div>${targetLine}</div>
|
||||||
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Ownership</span>
|
||||||
|
<div class="text-ink-mute">${ownLine}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recompute on any change in the form (path checks, radio swap, typed-confirm).
|
||||||
|
form.addEventListener('change', recompute);
|
||||||
|
form.addEventListener('input', recompute);
|
||||||
|
// Also after HTMX swaps in tree fragments (so initial state is right).
|
||||||
|
document.body.addEventListener('htmx:afterSwap', recompute);
|
||||||
|
recompute();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
|
|
||||||
{{/* ---------- progress (running only) ---------- */}}
|
{{/* ---------- progress (running only) ---------- */}}
|
||||||
{{if $page.IsActive}}
|
{{if $page.IsActive}}
|
||||||
<div class="mt-7" id="progress-block">
|
<div class="mt-7 panel rounded-[8px] p-[18px]" id="progress-block">
|
||||||
<div class="flex items-center justify-between mb-2.5">
|
<div class="flex items-center justify-between mb-2.5">
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3.5 text-sm">
|
||||||
<span class="mono text-ink font-medium" id="progress-pct">—</span>
|
<span class="mono text-ink font-medium" id="progress-pct" style="font-size: 18px;">—</span>
|
||||||
<span class="text-ink-mute" id="progress-bytes"></span>
|
<span class="text-ink-mute" id="progress-bytes"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-mute" id="progress-rate"></div>
|
<div class="text-sm text-ink-mute" id="progress-rate"></div>
|
||||||
@@ -86,6 +86,12 @@
|
|||||||
<div class="progress-track">
|
<div class="progress-track">
|
||||||
<div class="progress-fill" id="progress-fill" style="width: 0%;"></div>
|
<div class="progress-fill" id="progress-fill" style="width: 0%;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if eq (printf "%s" $job.Kind) "restore"}}
|
||||||
|
<div class="mt-3 text-[12px] text-ink-mute" id="restore-current-block">
|
||||||
|
<span class="text-ink-fade uppercase tracking-[0.08em] text-[10.5px]">Current</span>
|
||||||
|
<span class="mono text-ink-mid ml-2.5" id="restore-current-file">—</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -194,6 +200,18 @@
|
|||||||
return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i];
|
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) {
|
function appendLine(p) {
|
||||||
// Drop the "awaiting" placeholder once real lines arrive.
|
// Drop the "awaiting" placeholder once real lines arrive.
|
||||||
if (stream.children.length === 1 && stream.firstElementChild.textContent.includes('awaiting agent')) {
|
if (stream.children.length === 1 && stream.firstElementChild.textContent.includes('awaiting agent')) {
|
||||||
@@ -208,6 +226,7 @@
|
|||||||
`<span class="log-stream-${p.stream}">${escapeHtml(p.payload)}</span>`;
|
`<span class="log-stream-${p.stream}">${escapeHtml(p.payload)}</span>`;
|
||||||
stream.appendChild(line);
|
stream.appendChild(line);
|
||||||
if (autoScroll) container.scrollTop = container.scrollHeight;
|
if (autoScroll) container.scrollTop = container.scrollHeight;
|
||||||
|
maybeUpdateCurrent(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "tree_node"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
{{if $page.Error}}
|
||||||
|
<div class="px-3 py-2 mono text-[12px] text-bad">error: {{$page.Error}}</div>
|
||||||
|
{{else}}
|
||||||
|
{{/* parent path heading + collapse marker */}}
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 text-[12px] text-ink-mute border-b border-line-soft">
|
||||||
|
<span class="mono text-ink-mid">{{$page.Path}}</span>
|
||||||
|
{{if not $page.Children}}
|
||||||
|
<span class="text-ink-fade ml-auto mono text-[11px]">empty directory</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{range $page.Children}}
|
||||||
|
<div class="grid items-center gap-2 px-3 py-[5px] mono text-[12.5px] border-b border-line-soft last:border-b-0"
|
||||||
|
style="grid-template-columns: 14px 16px auto 1fr auto;">
|
||||||
|
{{if .IsDir}}
|
||||||
|
<button type="button"
|
||||||
|
class="text-ink-mute text-[10px] cursor-pointer"
|
||||||
|
hx-get="/hosts/{{$page.HostID}}/restore/tree?snapshot={{$page.SnapshotID}}&path={{.Path}}"
|
||||||
|
hx-target="next .tree-children"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="this.parentElement.querySelector('.tree-children').classList.toggle('hidden'); this.textContent = this.parentElement.querySelector('.tree-children').classList.contains('hidden') ? '▸' : '▾';">▸</button>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-ink-fade text-center">·</span>
|
||||||
|
{{end}}
|
||||||
|
<label class="cursor-pointer flex items-center justify-center">
|
||||||
|
<input type="checkbox" name="paths" value="{{.Path}}"
|
||||||
|
class="w-[13px] h-[13px] cursor-pointer" />
|
||||||
|
</label>
|
||||||
|
<span class="{{if .IsDir}}text-ink{{else}}text-ink-mid{{end}}">{{.Name}}{{if .IsDir}}/{{end}}</span>
|
||||||
|
<span></span>
|
||||||
|
<span class="text-[11px] text-ink-fade">{{if not .IsDir}}{{if .Size}}{{bytes .Size}}{{else}}—{{end}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .IsDir}}
|
||||||
|
<div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user