P2R-02 slice 2: Sources tab — list, new/edit form, delete, Run-now

Sources tab now lists every source group on the host with per-row
counts (used-by-N-schedules, snapshot count by tag), the v4
conflict tag (keep-* dimension that has no compatible cadence),
and Run-now / Edit / Delete actions. Run-now reuses the existing
HTMX-aware /hosts/{id}/source-groups/{gid}/run handler.

New /hosts/{id}/sources/new and /sources/{gid}/edit form: name +
includes/excludes textareas + the 3×2 keep-* retention grid +
retry-on-offline knobs. Server-side validation re-renders with the
operator's input intact; the inline conflict banner shows above the
retention grid when ConflictDimension is set.

Delete blocks (UI + server) when the group is referenced by any
schedule. Every successful mutation calls pushScheduleSetAsync so
an online agent re-arms within seconds.

Adds .src-row and .keep-cell to input.css for the row + retention
grid layout.
This commit is contained in:
2026-05-03 11:44:43 +01:00
parent 079b4bed70
commit da9ed4c3d4
6 changed files with 641 additions and 12 deletions
File diff suppressed because one or more lines are too long
+26
View File
@@ -186,6 +186,32 @@
.host-row.clickable > .row-link { pointer-events: auto; }
.host-row.clickable > .row-action { pointer-events: auto; }
/* ---------- source-group rows (Sources tab) ---------- */
.src-row {
display: grid; align-items: center;
grid-template-columns: 1fr auto;
column-gap: 18px;
padding: 14px 18px;
}
/* ---------- retention 3×2 keep-* grid (source-group edit) ---------- */
.keep-cell {
background: var(--bg);
border: 1px solid var(--line-soft);
border-radius: 5px;
padding: 9px 11px;
display: flex; flex-direction: column; gap: 4px;
}
.keep-cell label {
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--ink-fade);
}
.keep-cell input {
background: transparent; border: none; outline: none;
font-family: 'JetBrains Mono', monospace; font-size: 14px;
color: var(--ink); padding: 0; width: 100%;
}
/* ---------- log viewer ---------- */
.log {
background: var(--bg); border: 1px solid var(--line-soft);
+79 -4
View File
@@ -2,12 +2,87 @@
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6">
<div class="empty-state">
<h3 class="text-base font-medium tracking-[-0.005em]">Sources tab — coming next.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
The source-group editor lands in P2R-02 slice 2.
<div class="flex items-center justify-between mb-4">
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] max-w-[720px]">
Each source group is a named bundle of paths plus the rule for how long its snapshots stick around.
Schedules point at one or more groups — one <span class="mono text-ink-mid">restic backup</span> runs per group,
tagged by name so <span class="mono text-ink-mid">forget</span> can apply retention cleanly.
</p>
<a href="/hosts/{{$host.ID}}/sources/new" class="btn btn-primary whitespace-nowrap">+ New source group</a>
</div>
{{if eq (len $page.Groups) 0}}
<div class="panel rounded-[7px] empty-state" style="border-radius: 7px;">
<h3 class="text-base font-medium tracking-[-0.005em]">No source groups yet.</h3>
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.65]">
Create one to tell the agent what to back up. The group's name doubles as the snapshot tag.
</p>
<div class="mt-5">
<a href="/hosts/{{$host.ID}}/sources/new" class="btn btn-primary">+ New source group</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] overflow-hidden">
{{range $i, $row := $page.Groups}}
{{$g := $row.Group}}
<div class="src-row {{if not (eq $i 0)}}hairline{{end}}">
<div>
<div class="flex items-center" style="gap: 10px;">
<span class="tag mono" style="border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent);">{{$g.Name}}</span>
{{if $g.ConflictDimension}}
<span class="tag" title="keep-{{$g.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket. Either drop the keep-{{$g.ConflictDimension}} value or add a finer-grained schedule."
style="border-color: color-mix(in oklch, var(--warn), transparent 60%); color: var(--warn); cursor: help;">keep-{{$g.ConflictDimension}} · cadence mismatch</span>
{{end}}
</div>
<div class="mono text-[12px] text-ink-mid mt-2">
{{len $g.Includes}} include{{if ne (len $g.Includes) 1}}s{{end}} ·
{{len $g.Excludes}} exclude{{if ne (len $g.Excludes) 1}}s{{end}} ·
{{$g.RetentionPolicy.Summary}}
</div>
<div class="text-[11.5px] text-ink-fade mt-1">
{{if eq $row.UsedBy 0}}
used by 0 schedules
{{else}}
used by {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}}
{{end}}
{{if gt $row.SnapshotCount 0}} · <span class="mono">{{$row.SnapshotCount}}</span> snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}}
</div>
</div>
<div class="flex justify-end" style="gap: 6px;">
{{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}}
<button class="btn btn-primary"
hx-post="/hosts/{{$host.ID}}/source-groups/{{$g.ID}}/run"
hx-swap="none"
hx-disabled-elt="this">Run now</button>
{{else}}
<button class="btn" disabled
title="{{if eq (len $g.Includes) 0}}add at least one include path before running{{else}}host is offline{{end}}">Run now</button>
{{end}}
<a href="/hosts/{{$host.ID}}/sources/{{$g.ID}}/edit" class="btn">Edit</a>
{{if gt $row.UsedBy 0}}
<button class="btn btn-danger" disabled
title="remove this group from {{$row.UsedBy}} schedule{{if ne $row.UsedBy 1}}s{{end}} first">Delete</button>
{{else}}
<form method="post" action="/hosts/{{$host.ID}}/sources/{{$g.ID}}/delete" style="display: inline;"
onsubmit="return confirm('Delete source group &quot;{{$g.Name}}&quot;? Existing snapshots are not affected.');">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="text-[11.5px] text-ink-fade mt-4 leading-[1.65]">
Run-now on a row dispatches one immediate backup using that group's paths and tag.
Group <span class="mono text-ink-mid">name</span> is used as the snapshot tag — renaming a group
doesn't retag existing snapshots.
</div>
{{end}}
</div>
{{end}}
+132
View File
@@ -0,0 +1,132 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
{{$f := $page.Form}}
<div class="max-w-[1280px] mx-auto px-8 pb-24 pt-6">
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-1">
{{if $page.IsNew}}New source group{{else}}Edit source group <span class="mono text-ink-mid">·</span> <span class="mono">{{$f.Name}}</span>{{end}}
</h1>
<p class="text-pretty text-[13px] text-ink-mute max-w-[720px] mt-2 leading-[1.6]">
What this group covers and how long its snapshots are worth keeping.
Snapshots produced for this group carry the group's name as a tag —
rename with care: existing snapshots keep the old tag and won't get retained
by a renamed group's policy.
</p>
{{if $page.Error}}
<div class="mt-5 panel rounded-[6px] px-4 py-3 text-[13px]"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); color: var(--ink);">
{{$page.Error}}
</div>
{{end}}
<form method="post" action="{{$page.SaveAction}}" class="grid grid-cols-12 gap-8 mt-7">
<div class="col-span-7 panel rounded-[7px] p-7">
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Identity</h3>
<div class="mb-5">
<label class="field-label" for="name">Name</label>
<input type="text" id="name" name="name" class="field mono" value="{{$f.Name}}" autofocus
required pattern="[a-z0-9][a-z0-9_-]*" />
<div class="field-help">Used as the snapshot tag. Lowercase, no spaces; matches what <span class="mono text-ink-mid">restic forget --tag</span> sees.</div>
</div>
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-5 pt-4 border-t border-line-soft">Paths</h3>
<div class="mb-4">
<label class="field-label" for="includes">Includes <span class="text-ink-fade">· one path per line</span></label>
<textarea id="includes" name="includes" class="field mono" rows="4" style="resize: vertical;">{{$f.Includes}}</textarea>
<div class="field-help">What <span class="mono text-ink-mid">restic backup</span> walks. Agent runs as root with <span class="mono text-ink-mid">CAP_DAC_READ_SEARCH</span>, so any readable path is fair game.</div>
</div>
<div class="mb-5">
<label class="field-label" for="excludes">Excludes <span class="text-ink-fade">· optional, one pattern per line</span></label>
<textarea id="excludes" name="excludes" class="field mono" rows="3" style="resize: vertical;">{{$f.Excludes}}</textarea>
<div class="field-help">Passed straight through as <span class="mono text-ink-mid">--exclude</span> args.</div>
</div>
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-5 pt-4 border-t border-line-soft">
Retention
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">applied nightly · all blank = keep everything</span>
</h3>
{{if and (not $page.IsNew) $f.ConflictDimension}}
<div class="mb-3.5 flex gap-3 items-start rounded-[6px] px-3.5 py-3"
style="border: 1px solid color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">
<div class="text-[16px] leading-none text-warn pt-[1px]"></div>
<div class="text-[12.5px] text-ink-mid leading-[1.55]">
<strong class="text-ink">keep-{{$f.ConflictDimension}} is set, but no schedule pointing at this group fires often enough to populate that bucket.</strong>
Either drop <span class="mono text-ink">keep-{{$f.ConflictDimension}}</span> or add a finer-grained schedule.
</div>
</div>
{{end}}
<div class="grid grid-cols-3 gap-3">
<div class="keep-cell"><label>Keep last</label><input type="number" min="0" name="keep_last" value="{{$f.KeepLast}}" placeholder="—" /></div>
<div class="keep-cell"><label>Hourly</label><input type="number" min="0" name="keep_hourly" value="{{$f.KeepHourly}}" placeholder="—" /></div>
<div class="keep-cell"><label>Daily</label><input type="number" min="0" name="keep_daily" value="{{$f.KeepDaily}}" placeholder="—" /></div>
<div class="keep-cell"><label>Weekly</label><input type="number" min="0" name="keep_weekly" value="{{$f.KeepWeekly}}" placeholder="—" /></div>
<div class="keep-cell"><label>Monthly</label><input type="number" min="0" name="keep_monthly" value="{{$f.KeepMonthly}}" placeholder="—" /></div>
<div class="keep-cell"><label>Yearly</label><input type="number" min="0" name="keep_yearly" value="{{$f.KeepYearly}}" placeholder="—" /></div>
</div>
<div class="text-[11.5px] text-ink-fade mt-3 leading-[1.55]">
Blank fields stay unset (no constraint on that bucket). Forget runs nightly on the cadence configured on the
<a href="/hosts/{{$host.ID}}/repo" class="text-accent underline" style="text-underline-offset: 2px;">Repo tab</a>.
</div>
<h3 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5 mt-7 pt-4 border-t border-line-soft">
Retry on offline
<span class="text-ink-fade font-medium normal-case tracking-[0.01em] ml-2">cron-fired runs only</span>
</h3>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="retry_max">Max attempts</label>
<input type="number" min="0" id="retry_max" name="retry_max" class="field mono" value="{{$f.RetryMax}}" />
</div>
<div>
<label class="field-label" for="retry_backoff_seconds">Initial backoff (sec)</label>
<input type="number" min="0" id="retry_backoff_seconds" name="retry_backoff_seconds" class="field mono" value="{{$f.RetryBackoffSeconds}}" />
</div>
</div>
<div class="field-help mt-2">
Each retry doubles the wait. <strong>Manual run-now ignores this</strong> — it just fails immediately if the agent is offline.
</div>
<div class="mt-8 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">{{if $page.IsNew}}Create group{{else}}Save changes{{end}}</button>
<a href="/hosts/{{$host.ID}}/sources" class="btn btn-lg">Cancel</a>
</div>
</div>
<aside class="col-span-5">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-3.5">How this fits</div>
<ol class="list-none p-0 m-0 text-[13px]">
<li class="relative pl-9 pb-4">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
<div class="font-medium">Save here</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Bumps the host's schedule version; the agent picks up the new paths/retention on its next push (within seconds when online).</div>
</li>
<li class="relative pl-9 pb-4">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
<div class="font-medium">Schedules pointing here change behaviour</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Any schedule that includes this group in its picker now backs up the new paths next time it fires.</div>
</li>
<li class="relative pl-9 pb-4">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
<div class="font-medium">Retention applies on the next nightly forget</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Existing tagged snapshots get re-evaluated against the new keep-* rules. Untagged or differently-tagged snapshots are untouched.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span>
<div class="font-medium">Run-now from the Sources list</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Want to test? Save, go back to <a href="/hosts/{{$host.ID}}/sources" class="text-accent underline">Sources</a>, hit Run-now on this row.</div>
</li>
</ol>
</aside>
</form>
</div>
{{end}}