ui: Slice E — admin creds form + run-now buttons + repo health panel

- hostRepoPage gains AdminURL/AdminUsername/HasAdminPassword, Online,
  and StatsView (pre-dereferenced projection of host_repo_stats).
- loadHostRepoPage loads the admin slot (tolerating ErrNotFound),
  hub.Connected, and stats (tolerating ErrNotFound).
- renderRepoPage gains an adminErr parameter; all callers updated.
- handleUIAdminCredentialsSave / handleUIAdminCredentialsDelete added
  (form-POST handlers mirroring the repo-creds pattern, with audit).
- Routes /hosts/{id}/admin-credentials POST and /delete POST registered.
- Template: Admin credentials form after Connection, Run-now HTMX
  buttons after Maintenance, Repo health stats panel in right rail.
- Tests: 9 new tests covering rendering, disabled states, save/delete
  round-trips, audit rows, and idempotent delete.
This commit is contained in:
2026-05-03 23:18:16 +01:00
parent fb24e42c6e
commit 1cbc856514
4 changed files with 761 additions and 15 deletions
+114
View File
@@ -42,6 +42,54 @@
</div>
</form>
{{/* ---------- Admin credentials (optional) ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">
Admin credentials <span class="text-ink-fade normal-case">· prune-only · optional</span>
</h2>
<form method="post" action="/hosts/{{$host.ID}}/admin-credentials" class="panel rounded-[7px] p-5">
{{if $page.AdminCredsError}}
<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.AdminCredsError}}
</div>
{{end}}
{{if eq $page.SavedSection "admin_credentials"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
Only needed for rest-server repos that distinguish an append-only
user (everyday backups) from a delete-capable user (prune /
forget). For S3 / B2 / SFTP / local, leave this blank — the
everyday repo credentials handle prune too.
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="field-label" for="admin_repo_url">Repo URL <span class="text-ink-fade">· usually same as above</span></label>
<input id="admin_repo_url" name="repo_url" type="text" class="field mono" value="{{$page.AdminURL}}" />
</div>
<div>
<label class="field-label" for="admin_repo_username">Username</label>
<input id="admin_repo_username" name="repo_username" type="text" class="field mono" value="{{$page.AdminUsername}}" />
</div>
<div class="col-span-2">
<label class="field-label" for="admin_repo_password">Password</label>
<input id="admin_repo_password" name="repo_password" type="password" class="field mono"
placeholder="{{if $page.HasAdminPassword}}•••••••••••••••• · stored, leave blank to keep{{else}}— not yet set —{{end}}"
autocomplete="new-password" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2 items-center">
<button type="submit" class="btn btn-primary">Save admin credentials</button>
{{if $page.HasAdminPassword}}
<button type="submit" form="admin-creds-clear" class="btn btn-secondary"
onclick="return confirm('Clear admin credentials? Prune jobs will be refused until you re-set them.');">Clear</button>
{{end}}
</div>
</form>
{{if $page.HasAdminPassword}}
<form id="admin-creds-clear" method="post" action="/hosts/{{$host.ID}}/admin-credentials/delete"></form>
{{end}}
{{/* ---------- Bandwidth ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Bandwidth · host-wide</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/bandwidth" class="panel rounded-[7px] p-5">
@@ -138,6 +186,37 @@
</div>
</form>
{{/* ---------- Run now · one-time ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Run now · one-time</h2>
<div class="panel rounded-[7px] p-5">
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
Operator-triggered. Output streams live to the job log. Cadence-driven runs land independently from the server-side ticker.
</p>
<div class="grid grid-cols-3 gap-3">
<button type="button"
hx-post="/hosts/{{$host.ID}}/repo/check"
hx-confirm="Run check now ({{$m.CheckSubsetPct}}% data subset)?"
class="btn btn-secondary"
{{if not $page.Online}}disabled title="agent is offline"{{end}}>
check
</button>
<button type="button"
hx-post="/hosts/{{$host.ID}}/repo/prune"
hx-confirm="Run prune now? Removes data not referenced by any snapshot — heavy operation."
class="btn btn-secondary"
{{if not $page.HasAdminPassword}}disabled title="set admin credentials first"{{else if not $page.Online}}disabled title="agent is offline"{{end}}>
prune
</button>
<button type="button"
hx-post="/hosts/{{$host.ID}}/repo/unlock"
hx-confirm="Clear stale repo locks?"
class="btn btn-secondary"
{{if not $page.Online}}disabled title="agent is offline"{{end}}>
unlock
</button>
</div>
</div>
{{/* ---------- Danger zone ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-bad mt-9 mb-3.5">Danger zone</h2>
<div class="panel rounded-[7px] p-5"
@@ -179,6 +258,41 @@
</div>
</div>
{{/* ---------- Repo health ---------- */}}
{{if $page.StatsView}}
{{$s := $page.StatsView}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Repo health</h2>
<div class="panel rounded-[7px] p-5 text-[13px]">
{{if $s.LockPresent}}
<div class="rounded-[6px] px-3.5 py-3 text-[12.5px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">
Stale lock detected on the most recent check. Run <span class="mono">unlock</span> above to clear it before the next backup.
</div>
{{end}}
<dl class="grid grid-cols-2 gap-y-2 gap-x-4">
{{if $s.HasTotalSize}}
<dt class="text-ink-fade">Total size</dt>
<dd class="mono text-right">{{bytes $s.TotalSizeBytes}}</dd>
{{end}}
{{if $s.HasRawSize}}
<dt class="text-ink-fade">Raw size <span class="text-ink-fade text-[11px]">· pre-dedup</span></dt>
<dd class="mono text-right">{{bytes $s.RawSizeBytes}}</dd>
{{end}}
{{if $s.HasLastCheck}}
<dt class="text-ink-fade">Last check</dt>
<dd class="mono text-right text-[12px]">
{{$s.LastCheckAgo}}
{{if $s.LastCheckStatus}} · <span class="{{if eq $s.LastCheckStatus "ok"}}text-ok{{else if eq $s.LastCheckStatus "errors_found"}}text-bad{{else}}text-ink-mid{{end}}">{{$s.LastCheckStatus}}</span>{{end}}
</dd>
{{end}}
{{if $s.HasLastPrune}}
<dt class="text-ink-fade">Last prune</dt>
<dd class="mono text-right text-[12px]">{{$s.LastPruneAgo}}</dd>
{{end}}
</dl>
</div>
{{end}}
{{if gt (len $page.GroupNames) 0}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Snapshots by source</h2>
<div class="panel rounded-[7px] p-4">