P3 sweep fixes: snap-row CSS, tree expand, --no-ownership drop, target path
Bug fixes from the Playwright sweep against the live smoke server:
1. Snapshot-picker layout. The .snap-row class was used in the wireframe
but never landed in web/styles/input.css; rows rendered as vertical
blocks instead of a 6-column grid. Added the token (mirrors host-row
shape with restore-specific column widths).
2. Tree expansion. hx-target='closest .tree-row + .tree-children' isn't
a valid HTMX selector — modifiers don't chain. Replaced HTMX-driven
expansion with a small window.__rmTreeToggle helper that uses plain
fetch + .tree-pair wrapper structure for trivial sibling lookup.
Caches loaded state per node.
3. --no-ownership flag dropped. Restic 0.17 introduced --no-ownership;
0.16 rejects it ('unknown flag') before doing any work. Since the
agent runs as root in the systemd unit, restored files keep their
original uid/gid either way and the parent dir is root-owned, so
the 'cp without sudo' rationale doesn't hold. Drop the flag entirely.
4. Default target dir moved to /var/lib/restic-manager/restore. The
systemd unit pins ReadWritePaths to /etc/restic-manager +
/var/lib/restic-manager (with ProtectSystem=strict making the rest
of /var read-only); writes to /var/restic-restore failed with
'read-only file system'.
5. Confirm summary HTML escaping. defaultTarget JS literal evaluates
to a string with literal angle brackets; insertion into innerHTML
must escape them. Added an inline HTML-escape pass.
tasks.md ticked for the Restore sub-phase with a sweep summary
covering the live end-to-end test.
This commit is contained in:
@@ -141,9 +141,11 @@ esac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunRestoreNewDirArgvHasNoOwnership: complement of the above —
|
// TestRunRestoreNewDirArgvShape: non-in-place restore passes --target
|
||||||
// non-in-place restore must include --no-ownership.
|
// to the operator-chosen new directory and includes the path filters.
|
||||||
func TestRunRestoreNewDirArgvHasNoOwnership(t *testing.T) {
|
// We deliberately do NOT pass --no-ownership (added in restic 0.17;
|
||||||
|
// older versions error out — the comment in restore.go explains why).
|
||||||
|
func TestRunRestoreNewDirArgvShape(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
bin := setupScript(t, `
|
bin := setupScript(t, `
|
||||||
@@ -175,8 +177,8 @@ esac
|
|||||||
if argv == "" {
|
if argv == "" {
|
||||||
t.Fatal("no argv echo")
|
t.Fatal("no argv echo")
|
||||||
}
|
}
|
||||||
if !strings.Contains(argv, "--no-ownership") {
|
if strings.Contains(argv, "--no-ownership") {
|
||||||
t.Errorf("new-dir restore should pass --no-ownership; got argv=%q", argv)
|
t.Errorf("restic 0.16 doesn't accept --no-ownership; got argv=%q", argv)
|
||||||
}
|
}
|
||||||
if !strings.Contains(argv, "--target /tmp/restore-out") {
|
if !strings.Contains(argv, "--target /tmp/restore-out") {
|
||||||
t.Errorf("expected --target /tmp/restore-out; got argv=%q", argv)
|
t.Errorf("expected --target /tmp/restore-out; got argv=%q", argv)
|
||||||
|
|||||||
@@ -65,9 +65,15 @@ func (e Env) RunRestore(ctx context.Context, snapshotID string, paths []string,
|
|||||||
target = "/"
|
target = "/"
|
||||||
}
|
}
|
||||||
args = append(args, "--target", target)
|
args = append(args, "--target", target)
|
||||||
if !inPlace {
|
// NOTE: restic added --no-ownership in 0.17. Older versions reject
|
||||||
args = append(args, "--no-ownership")
|
// the flag with "unknown flag: --no-ownership" before doing any
|
||||||
}
|
// work. Since the agent runs as root in the systemd unit, files
|
||||||
|
// land under /var/restic-restore with their original uid/gid
|
||||||
|
// either way — the original "cp without sudo" rationale doesn't
|
||||||
|
// hold (operators copying from /var/restic-restore need sudo
|
||||||
|
// regardless because the parent dir is root-owned). Drop the flag
|
||||||
|
// entirely until we drop 0.16 support; revisit if a non-root
|
||||||
|
// agent deployment requirement comes back.
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
args = append(args, "--include", p)
|
args = append(args, "--include", p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,11 +384,14 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
// defaultRestoreTargetRoot is the parent of the per-job restore
|
// defaultRestoreTargetRoot is the parent of the per-job restore
|
||||||
// directory. Chosen on a per-host basis would be nicer but the agent
|
// directory. The agent's systemd unit pins ReadWritePaths to
|
||||||
// is the one that actually creates it, and /var/restic-restore is
|
// /etc/restic-manager + /var/lib/restic-manager (with ProtectSystem=
|
||||||
// fine for Linux hosts (the agent's systemd unit runs as root).
|
// strict making the rest of /var read-only); restore writes have to
|
||||||
|
// land inside one of those, so we keep them under
|
||||||
|
// /var/lib/restic-manager/restore where the agent is already allowed
|
||||||
|
// to write. The /restore subdir is created by the agent on demand.
|
||||||
func defaultRestoreTargetRoot() string {
|
func defaultRestoreTargetRoot() string {
|
||||||
return "/var/restic-restore"
|
return "/var/lib/restic-manager/restore"
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultRestoreTargetDir surfaces the placeholder path shown on the
|
// defaultRestoreTargetDir surfaces the placeholder path shown on the
|
||||||
|
|||||||
@@ -302,8 +302,8 @@ func TestRestorePostHappyPathDispatches(t *testing.T) {
|
|||||||
if cp.Restore.InPlace {
|
if cp.Restore.InPlace {
|
||||||
t.Fatal("expected new-directory mode (in_place=false)")
|
t.Fatal("expected new-directory mode (in_place=false)")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(cp.Restore.TargetDir, "/var/restic-restore/") {
|
if !strings.HasPrefix(cp.Restore.TargetDir, "/var/lib/restic-manager/restore/") {
|
||||||
t.Fatalf("target_dir: got %q, want prefix /var/restic-restore/", cp.Restore.TargetDir)
|
t.Fatalf("target_dir: got %q, want prefix /var/lib/restic-manager/restore/", cp.Restore.TargetDir)
|
||||||
}
|
}
|
||||||
if len(cp.Restore.Paths) != 2 {
|
if len(cp.Restore.Paths) != 2 {
|
||||||
t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths))
|
t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths))
|
||||||
|
|||||||
@@ -246,18 +246,24 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
|||||||
> doesn't have a confirmed need yet, so it's moved to the **Future /
|
> doesn't have a confirmed need yet, so it's moved to the **Future /
|
||||||
> unscheduled** section at the end of this file.
|
> unscheduled** section at the end of this file.
|
||||||
|
|
||||||
### Phase 3 — Restore (in progress, brand `p3-restore`)
|
### Phase 3 — Restore ✅
|
||||||
|
|
||||||
> Spec: `docs/superpowers/specs/2026-05-04-p3-restore-design.md`.
|
> Spec: `docs/superpowers/specs/2026-05-04-p3-restore-design.md`.
|
||||||
> Wireframe: `_diag/p3-restore-wizard/wireframe.html`.
|
> Wireframe: `_diag/p3-restore-wizard/wireframe.html`.
|
||||||
|
> Sweep screenshots: `_diag/p3-restore-sweep/`.
|
||||||
|
> Shipped on branch `p3-restore`.
|
||||||
|
|
||||||
- [ ] **P3-X1** (S) Cancel-job feature. New `command.cancel` WS envelope; agent tracks per-job ctx.CancelFunc and kills the running `restic` subprocess (SIGTERM, SIGKILL after 5s grace); server endpoint `POST /api/jobs/{id}/cancel` bridges UI → WS; the existing UI Cancel button on `/jobs/{id}` becomes real for any running kind. Foundational — restore depends on it.
|
- [x] **P3-X1** (S) Cancel-job feature. `command.cancel` WS envelope; agent tracks per-job ctx.CancelFunc and kills the running `restic` subprocess via context cancel (SIGTERM, SIGKILL after 5s grace via `cmd.Cancel` + `cmd.WaitDelay`); server endpoint `POST /api/jobs/{id}/cancel` bridges UI → WS; the existing UI Cancel button on `/jobs/{id}` is now real for any running kind. Sandbox-aware: `internal/restic/cancel_{unix,windows}.go` build-tags pick SIGTERM on POSIX vs `os.Kill` on Windows (which can't deliver SIGTERM). Tests: cancel mid-run via 'sleep 30' fake-restic returns JobCancelled with exit 130 in <200ms.
|
||||||
- [ ] **P3-X2** (S) Tree-list synchronous WS RPC. New `tree.list` request / `tree.list.result` reply on the existing correlation-ID infra; agent runs `restic ls --json <sid> <path>` per call; server-side mediator `ws.SendRPC` + per-wizard-session in-memory cache (~30-min TTL).
|
- [x] **P3-X2** (S) Tree-list synchronous WS RPC. `MsgTreeList` ↔ `MsgTreeListResult` with `Envelope.ID` correlation; generic `Hub.SendRPC` helper (registry of buffered channels keyed by ULID, ctx-cancel + timeout aware). `internal/restic.ListTreeChildren` wraps `restic ls --json` and filters its recursive output to direct children. Server-side `treeCache` is per-wizard-session (keyed by session cookie + host + snapshot + path) with a 30-min TTL and lazy sweep.
|
||||||
- [ ] **P3-01** (L) Restore wizard backend: tree browse via `tree.list` RPC (P3-X2), path picker validation, target selection (new-dir vs in-place + typed-confirm), dispatch endpoint `POST /hosts/{id}/restore`, audit row `host.restore`.
|
- [x] **P3-01** (L) Restore wizard backend (`internal/server/http/ui_restore.go`). GET handlers render the four-step wizard against the wireframe. HTMX/fetch tree partial endpoint hits `fetchTreeWithCache`. POST validates: snapshot_id, ≥1 absolute path, in-place ⇒ confirm_hostname == host name, agent online; on error re-renders with operator's input intact. Happy path mints job_id, target = `/var/lib/restic-manager/restore/<job-id>` (server-picked, agent's writable dir under the systemd sandbox's `ReadWritePaths`), creates job row, ships `command.run` with `RestorePayload`, writes `host.restore` audit row, returns HX-Redirect (or 303) to the live job page.
|
||||||
- [ ] **P3-02** (L) Restore wizard UI: single-page progressively-enabled four-step form at `/hosts/{id}/restore` (and pre-selected variant `/hosts/{id}/snapshots/{sid}/restore`); tree-browser HTMX partials. Top-level "Restore" button on host detail.
|
- [x] **P3-02** (L) Wizard UI templates (`web/templates/pages/host_restore.html` + `partials/tree_node.html`). Single-page progressively-enabled four-step form. Form-state-driven JS computes a running tally + step-4 confirm summary client-side. Tree expansion uses plain fetch (not HTMX) for simpler target lookup; loaded-state cached per node. Top-level Restore button on host detail right rail + per-snapshot Restore action on snapshot rows. New `.snap-row` token in `web/styles/input.css`.
|
||||||
- [ ] **P3-03** (M) Restore execution: `restic.RunRestore` (paths, --target, --no-ownership for new-dir; preserves ownership for in-place); agent dispatcher case `JobRestore`; restore-specific job page variant with files-restored / bytes-restored / throughput / ETA / current-file widget.
|
- [x] **P3-03** (M) Restore execution. `restic.RunRestore` builds `restore <sid> --target <dir> [--include p]...` with --json; new `pumpRestoreStdout` parses status + summary objects. `--no-ownership` is **not** passed — restic 0.17 added that flag, 0.16 errors out, and the agent's systemd unit runs as root anyway so the original "cp without sudo" rationale doesn't hold (parent dir is root-owned regardless). `runner.RunRestore` translates `RestoreStatus` into `job.progress` (mapping FilesRestored → FilesDone, etc.); agent dispatcher case `JobRestore` reuses the `spawn()` helper from P3-X1 so cancel works. Restore-shaped job-detail variant with current-file display under the progress bar.
|
||||||
- [ ] **P3-09** (S) `diff` between two snapshots in UI: `JobDiff` JobKind, `restic.RunDiff`, `POST /api/hosts/{id}/snapshots/diff` dispatcher, snapshot-picker UI on Snapshots tab to pick A+B; output streams as `log.stream` to the standard live job log page.
|
- [x] **P3-09** (S) `diff` between two snapshots. `JobDiff` JobKind + `restic.RunDiff` + `runner.RunDiff`; `POST /api/hosts/{id}/snapshots/diff` (and HTMX-form variant on the unprefixed path) dispatcher with two-snapshot guard + per-host snapshot-list validation; UI panel on host detail right rail (visible when 2+ snapshots) with two short-id inputs + Diff button. Output streams as log.stream to the standard live job log page.
|
||||||
- [ ] **P3-X3** (S) Recent-restores panel on host detail: small line below the existing init-status, surfacing latest `JobRestore` outcome (succeeded N hours ago / failed → live log link). Backed by `store.LatestJobByKind(host_id, JobRestore)`.
|
- [x] **P3-X3** (S) Recent-restores line on host detail. `hostChromeData` grows `RestoreStatus` / `RestoreAt` / `RestoreJobID` populated via `store.LatestJobByKind(host_id, 'restore')` (already exists from P2R). `host_chrome.html` renders a small line below the init-status one with status-coloured copy + a link to the job log. Hidden when no restore has ever run on this host.
|
||||||
|
|
||||||
|
> **Migration 0012** widens the `jobs.kind` CHECK constraint to include `restore` and `diff`. Rebuild required (SQLite can't ALTER CHECK in place); follows the safe pattern from 0005, with a defensive temp-table backup of `job_logs` so the cascade-trap that bit migration 0007 wouldn't take the log history with it.
|
||||||
|
|
||||||
|
> **As shipped (Playwright sweep against the live smoke env, 2026-05-04):** login → host detail → Restore button → wizard step 1 picks snapshot a1ac4006 (most recent) → tree drill-down `/home/steve/test` (3 lazy loads) → tick `file1` + `file2` → step 4 confirm summary populated → dispatch → live job page with running progress widget → restore succeeds, files land on disk at `/var/lib/restic-manager/restore/<job-id>/home/steve/test/file{1,2}`. Snapshot diff between `a1ac4006` and `5f78c788` → diff job page, statistics output streamed (738 bytes added, 0 removed). Recent-restores line on host detail reads "last restore · succeeded 28s ago · job log →".
|
||||||
|
|
||||||
### Phase 3 — Alerts (not started)
|
### Phase 3 — Alerts (not started)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -206,6 +206,25 @@
|
|||||||
.src-row.clickable > .row-link { pointer-events: auto; }
|
.src-row.clickable > .row-link { pointer-events: auto; }
|
||||||
.src-row.clickable > .row-action { pointer-events: auto; }
|
.src-row.clickable > .row-action { pointer-events: auto; }
|
||||||
|
|
||||||
|
/* ---------- snapshot picker rows (Restore wizard step 1) ---------- */
|
||||||
|
.snap-row {
|
||||||
|
display: grid; align-items: center;
|
||||||
|
grid-template-columns: 150px 130px 1fr 90px 130px 80px;
|
||||||
|
column-gap: 16px;
|
||||||
|
padding: 11px 14px; font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 100ms ease;
|
||||||
|
}
|
||||||
|
.snap-row:last-child { border-bottom: 0; }
|
||||||
|
.snap-row:hover { background: var(--panel-hi); }
|
||||||
|
.snap-row.head {
|
||||||
|
font-size: 11px; color: var(--ink-fade);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding-top: 9px; padding-bottom: 9px; cursor: default;
|
||||||
|
}
|
||||||
|
.snap-row.head:hover { background: transparent; }
|
||||||
|
|
||||||
/* ---------- schedule rows (Schedules tab) ---------- */
|
/* ---------- schedule rows (Schedules tab) ---------- */
|
||||||
.schd-row {
|
.schd-row {
|
||||||
display: grid; align-items: center;
|
display: grid; align-items: center;
|
||||||
|
|||||||
@@ -114,13 +114,22 @@
|
|||||||
<div class="p-[18px]">
|
<div class="p-[18px]">
|
||||||
{{if $page.Selected}}
|
{{if $page.Selected}}
|
||||||
<div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden p-2">
|
<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. */}}
|
{{/* Root tree node — fetched on first wizard render; child
|
||||||
<div hx-get="/hosts/{{$host.ID}}/restore/tree?snapshot={{$page.Selected.ID}}&path=/"
|
expansions reuse the same tree.list cache server-side. */}}
|
||||||
hx-trigger="load"
|
<div id="tree-root">
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</div>
|
<div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
fetch('/hosts/{{$host.ID}}/restore/tree?snapshot={{$page.Selected.ID}}&path=/', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(html) {
|
||||||
|
document.getElementById('tree-root').innerHTML = html;
|
||||||
|
document.body.dispatchEvent(new CustomEvent('tree:loaded'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div class="mt-3 px-3.5 py-2.5 rounded-[6px] text-[12.5px]"
|
<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%);">
|
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-accent" id="tally-count">0 files selected</span>
|
||||||
@@ -158,7 +167,7 @@
|
|||||||
<div class="text-[14px] font-medium text-ink">New directory</div>
|
<div class="text-[14px] font-medium text-ink">New directory</div>
|
||||||
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
|
||||||
Files restore into a fresh path on the host. Original files untouched.
|
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.
|
Original ownership (uid/gid/mode) is preserved.
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 px-3 py-[9px] rounded-[5px] mono text-[12px] text-ink flex items-center gap-2.5"
|
<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);">
|
style="background: var(--bg); border: 1px solid var(--line-soft);">
|
||||||
@@ -237,10 +246,42 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* Lightweight JS to drive the live tally + summary card. No HTMX
|
{{/* Lightweight JS to drive the live tally + summary card + tree toggle.
|
||||||
here; the tree HTML is HTMX-loaded but the running tally is just
|
The tree-toggle is plain fetch (not HTMX) so its target lookup is
|
||||||
reading the form state on click. */}}
|
trivial — the .tree-children div is always the next sibling
|
||||||
|
inside the same .tree-pair wrapper. */}}
|
||||||
<script>
|
<script>
|
||||||
|
window.__rmTreeToggle = function(btn) {
|
||||||
|
var pair = btn.closest('.tree-pair');
|
||||||
|
if (!pair) return;
|
||||||
|
var kids = pair.querySelector(':scope > .tree-children');
|
||||||
|
if (!kids) return;
|
||||||
|
var loaded = btn.getAttribute('data-loaded') === 'true';
|
||||||
|
if (!loaded) {
|
||||||
|
var url = btn.getAttribute('data-tree-url');
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch(url, { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(html) {
|
||||||
|
kids.innerHTML = html;
|
||||||
|
kids.classList.remove('hidden');
|
||||||
|
btn.textContent = '▾';
|
||||||
|
btn.setAttribute('data-loaded', 'true');
|
||||||
|
btn.disabled = false;
|
||||||
|
// Notify the wizard's recompute() that tally state may have changed.
|
||||||
|
document.body.dispatchEvent(new CustomEvent('tree:loaded'));
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
kids.innerHTML = '<div class="px-3 py-2 mono text-[12px] text-bad">load failed: ' + e + '</div>';
|
||||||
|
kids.classList.remove('hidden');
|
||||||
|
btn.textContent = '▾';
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
kids.classList.toggle('hidden');
|
||||||
|
btn.textContent = kids.classList.contains('hidden') ? '▸' : '▾';
|
||||||
|
};
|
||||||
(function() {
|
(function() {
|
||||||
const form = document.getElementById('restore-form');
|
const form = document.getElementById('restore-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -292,12 +333,12 @@
|
|||||||
summary.innerHTML = '<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you\'ve made your selections.</div>';
|
summary.innerHTML = '<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you\'ve made your selections.</div>';
|
||||||
} else {
|
} else {
|
||||||
const inPlace = inplaceRadio && inplaceRadio.checked;
|
const inPlace = inplaceRadio && inplaceRadio.checked;
|
||||||
|
const escTarget = defaultTarget
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
const targetLine = inPlace
|
const targetLine = inPlace
|
||||||
? '<span class="text-bad">in place · originals will be overwritten</span>'
|
? '<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>';
|
: '<span class="text-ink">New directory</span> <span class="text-ink-fade mx-2">·</span> <span class="mono text-ink-mid">' + escTarget + '</span>';
|
||||||
const ownLine = inPlace
|
const ownLine = 'preserved (uid/gid/mode/mtime)';
|
||||||
? '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 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>') : '';
|
const more = paths.length > 12 ? ('<div class="text-ink-fade">… and ' + (paths.length - 12) + ' more</div>') : '';
|
||||||
summary.innerHTML = `
|
summary.innerHTML = `
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
{{if $page.Error}}
|
{{if $page.Error}}
|
||||||
<div class="px-3 py-2 mono text-[12px] text-bad">error: {{$page.Error}}</div>
|
<div class="px-3 py-2 mono text-[12px] text-bad">error: {{$page.Error}}</div>
|
||||||
{{else}}
|
{{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">
|
<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>
|
<span class="mono text-ink-mid">{{$page.Path}}</span>
|
||||||
{{if not $page.Children}}
|
{{if not $page.Children}}
|
||||||
@@ -11,29 +10,30 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{range $page.Children}}
|
{{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"
|
<div class="tree-pair">
|
||||||
style="grid-template-columns: 14px 16px auto 1fr auto;">
|
<div class="grid items-center gap-2 px-3 py-[5px] mono text-[12.5px] border-b border-line-soft"
|
||||||
|
style="grid-template-columns: 14px 16px auto 1fr auto;">
|
||||||
|
{{if .IsDir}}
|
||||||
|
<button type="button"
|
||||||
|
class="tree-toggle text-ink-mute text-[10px] cursor-pointer"
|
||||||
|
data-tree-url="/hosts/{{$page.HostID}}/restore/tree?snapshot={{$page.SnapshotID}}&path={{.Path}}"
|
||||||
|
data-loaded="false"
|
||||||
|
onclick="window.__rmTreeToggle(this)">▸</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}}
|
{{if .IsDir}}
|
||||||
<button type="button"
|
<div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
|
||||||
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}}
|
{{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>
|
</div>
|
||||||
{{if .IsDir}}
|
|
||||||
<div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user