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:
2026-05-04 15:57:42 +01:00
parent c417b5e9ab
commit 65a0134101
9 changed files with 133 additions and 56 deletions
+53 -12
View File
@@ -114,13 +114,22 @@
<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">
{{/* Root tree node — fetched on first wizard render; child
expansions reuse the same tree.list cache server-side. */}}
<div id="tree-root">
<div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</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]"
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>
@@ -158,7 +167,7 @@
<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.
Original ownership (uid/gid/mode) is preserved.
</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);">
@@ -237,10 +246,42 @@
</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. */}}
{{/* Lightweight JS to drive the live tally + summary card + tree toggle.
The tree-toggle is plain fetch (not HTMX) so its target lookup is
trivial — the .tree-children div is always the next sibling
inside the same .tree-pair wrapper. */}}
<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() {
const form = document.getElementById('restore-form');
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>';
} else {
const inPlace = inplaceRadio && inplaceRadio.checked;
const escTarget = defaultTarget
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>';
: '<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 = 'preserved (uid/gid/mode/mtime)';
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 = `