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:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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 = `
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
{{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}}
|
||||
@@ -11,29 +10,30 @@
|
||||
{{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;">
|
||||
<div class="tree-pair">
|
||||
<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}}
|
||||
<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>
|
||||
<div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
|
||||
{{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