Files
restic-manager/web/templates/pages/host_repo.html
T
Steve Cliff a8a6fdfab5
CI / Test (store) (pull_request) Successful in 37s
CI / Test (rest) (pull_request) Successful in 48s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 20s
CI / Build (linux/arm64) (pull_request) Successful in 24s
CI / Build (linux/amd64) (pull_request) Successful in 26s
CI / Test (server-http) (pull_request) Successful in 1m37s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m16s
docs: address review — make S3/B2/SFTP/local guidance actionable
The previous wording told operators to leave the slot blank and then
warned that doing so disables prune, which is contradictory. Prune is
gated on the admin slot for every backend, on both paths:
scheduled (maintenance_dispatch.go:52) skips silently, manual
(repo_ops.go:39) returns 400 admin_creds_required.

Also reworded the opening line, which still said 'only needed for
rest-server repos' while the new text asks other backends to fill it in.
2026-08-21 22:36:14 +01:00

397 lines
22 KiB
HTML

{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "host_chrome" .}}
{{$page := .Page}}
{{$host := $page.Host}}
<div class="max-w-[1280px] mx-auto px-8 pb-14 pt-6 grid grid-cols-12 gap-6 items-start">
<div class="col-span-8">
{{/* ---------- Repo status (NS-03) ---------- */}}
{{if eq $host.RepoStatus "init_failed"}}
<div class="rounded-[7px] px-4 py-3.5 mb-5"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 55%); background: color-mix(in oklch, var(--bad), transparent 90%);">
<div class="flex items-center justify-between gap-3 mb-1.5">
<div class="text-[12.5px] font-semibold text-bad uppercase tracking-[0.08em]">Repo unreachable</div>
<form method="post" action="/hosts/{{$host.ID}}/repo/probe">
<button type="submit" class="btn btn-sm"
{{if $page.Online}}{{else}}disabled title="host is offline"{{end}}>Retry probe</button>
</form>
</div>
<div class="text-[12.5px] text-ink-mid leading-[1.55]">
The last init / probe against this host's repo failed. Fix the
credentials below and save (the save kicks a fresh probe), or
click <span class="mono">Retry probe</span> if you've changed
something out-of-band.
</div>
{{if $host.RepoStatusError}}
<pre class="mono text-[11.5px] text-ink-mid mt-2.5 whitespace-pre-wrap leading-[1.5]">{{$host.RepoStatusError}}</pre>
{{end}}
</div>
{{else if eq $host.RepoStatus "ready"}}
<div class="text-[12px] text-ok mono mb-5">✓ repo reachable with current credentials</div>
{{end}}
{{/* ---------- Connection ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Connection</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/credentials" class="panel rounded-[7px] p-5">
{{if $page.CredentialsError}}
<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.CredentialsError}}
</div>
{{end}}
{{if eq $page.SavedSection "credentials"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="field-label" for="repo_url">Repo URL</label>
<input id="repo_url" name="repo_url" type="text" class="field mono" value="{{$page.RepoURL}}" required />
<div class="field-help">e.g. <span class="mono text-ink-mid">rest:http://192.168.0.99:8000/{{$host.Name}}/</span></div>
</div>
<div>
<label class="field-label" for="repo_username">Username</label>
<input id="repo_username" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}" />
<div class="field-help">Sent as the rest-server <span class="mono text-ink-mid">--htpasswd</span> user.</div>
</div>
<div class="col-span-2">
<label class="field-label" for="repo_password">Password</label>
<input id="repo_password" name="repo_password" type="password" class="field mono" placeholder="{{if $page.HasPassword}}•••••••••••••••• · stored, leave blank to keep{{else}}— not yet set —{{end}}" autocomplete="new-password" />
<div class="field-help">Stored AEAD-encrypted; pushed to the agent over WS. Leave blank to keep the existing password.</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary">Save credentials</button>
</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]">
Required for prune. On rest-server repos this is the
delete-capable user, as distinct from the append-only user used
for everyday backups. Note that <strong>forget</strong> always
runs with the everyday repo credentials, so those must have
delete authority. For S3 / B2 / SFTP / local, enter the same
delete-capable repository credentials here if you want prune
enabled. <strong>Prune is skipped when admin credentials are
unset</strong>, on any backend.
</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">
{{if $page.BandwidthError}}
<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.BandwidthError}}
</div>
{{end}}
{{if eq $page.SavedSection "bandwidth"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="field-label" for="bandwidth_up">Upload limit <span class="text-ink-fade">· KB/s · blank = no cap</span></label>
<input id="bandwidth_up" name="bandwidth_up" type="number" min="0" class="field mono" value="{{$page.BandwidthUp}}" placeholder="—" />
</div>
<div>
<label class="field-label" for="bandwidth_down">Download limit <span class="text-ink-fade">· KB/s · blank = no cap</span></label>
<input id="bandwidth_down" name="bandwidth_down" type="number" min="0" class="field mono" value="{{$page.BandwidthDown}}" placeholder="—" />
</div>
</div>
<div class="field-help mt-3">
Applies to every backup, restore, and prune job for this host. Maps to <span class="mono text-ink-mid">restic --limit-upload</span> / <span class="mono text-ink-mid">--limit-download</span>.
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2">
<button type="submit" class="btn btn-primary">Save bandwidth caps</button>
</div>
</form>
{{/* ---------- Maintenance ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Maintenance · server-side cadences</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/maintenance" class="panel rounded-[7px] p-5">
{{if $page.MaintenanceError}}
<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.MaintenanceError}}
</div>
{{end}}
{{if eq $page.SavedSection "maintenance"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
{{$m := $page.Maintenance}}
<div class="grid grid-cols-12 gap-3 items-center text-[13px] mb-3 text-[11px] uppercase tracking-[0.08em] text-ink-fade">
<div class="col-span-2">Verb</div>
<div class="col-span-5">Cron cadence</div>
<div class="col-span-3">Notes</div>
<div class="col-span-2 text-right">Enabled</div>
</div>
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
<div class="col-span-2 mono text-ink font-medium">forget</div>
<div class="col-span-5"><input type="text" name="forget_cron" class="field mono" value="{{$m.ForgetCron}}" required /></div>
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">Per source group, using each group's retention policy.</div>
<div class="col-span-2 text-right">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="forget_enabled" value="1" {{if $m.ForgetEnabled}}checked{{end}} class="w-3.5 h-3.5" />
<span class="mono text-[11px]">on</span>
</label>
</div>
</div>
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
<div class="col-span-2 mono text-ink font-medium">prune</div>
<div class="col-span-5"><input type="text" name="prune_cron" class="field mono" value="{{$m.PruneCron}}" required /></div>
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">Reclaims storage made dead by forget. Heavy — weekly only.</div>
<div class="col-span-2 text-right">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="prune_enabled" value="1" {{if $m.PruneEnabled}}checked{{end}} class="w-3.5 h-3.5" />
<span class="mono text-[11px]">on</span>
</label>
</div>
</div>
<div class="grid grid-cols-12 gap-3 items-center py-3 border-t border-line-soft">
<div class="col-span-2 mono text-ink font-medium">check</div>
<div class="col-span-5"><input type="text" name="check_cron" class="field mono" value="{{$m.CheckCron}}" required /></div>
<div class="col-span-3 text-[12px] text-ink-fade leading-[1.5]">
<span class="mono text-ink-mid">--read-data-subset</span>
<input type="number" name="check_subset_pct" min="0" max="100" value="{{$m.CheckSubsetPct}}" class="field mono inline-block w-16 px-2 py-1" style="font-size: 11px;" />%
</div>
<div class="col-span-2 text-right">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="check_enabled" value="1" {{if $m.CheckEnabled}}checked{{end}} class="w-3.5 h-3.5" />
<span class="mono text-[11px]">on</span>
</label>
</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 cadences</button>
<span class="text-[12px] text-ink-fade ml-2">Server-side ticker drives execution — independent of the agent's cron.</span>
</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-swap="none"
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-swap="none"
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-swap="none"
hx-confirm="Clear stale repo locks?"
class="btn btn-secondary"
{{if not $page.Online}}disabled title="agent is offline"{{end}}>
unlock
</button>
</div>
</div>
{{/* ---------- Trend ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Trend</h2>
<div class="panel rounded-[7px] p-5">
{{template "repo_size_chart" (dict "Page" $page.Trend)}}
</div>
{{/* ---------- Host-default hooks ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-9 mb-3.5">Host-default hooks</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/hooks" class="panel rounded-[7px] p-5">
<p class="text-[12px] text-ink-mute leading-[1.55] mb-3">
Defaults applied to every backup that doesn't set its own. Per-source-group hooks (on the
<a href="/hosts/{{$host.ID}}/sources" class="text-accent">Sources</a> tab) override these.
</p>
<div class="text-[12px] text-warn leading-[1.55] mb-3"
style="background: color-mix(in oklch, var(--warn), transparent 92%); border: 1px solid color-mix(in oklch, var(--warn), transparent 75%); padding: 8px 10px; border-radius: 5px;">
Hooks run as the agent service user — root on Linux, LocalSystem on Windows.
</div>
<div class="mb-3">
<label class="field-label" for="host_pre_hook">Pre-backup hook (default)</label>
<textarea id="host_pre_hook" name="pre_hook" class="field mono" rows="3" style="resize: vertical;"
placeholder="# default; per-group overrides win">{{$page.HostPreHook}}</textarea>
</div>
<div class="mb-3">
<label class="field-label" for="host_post_hook">Post-backup hook (default)</label>
<textarea id="host_post_hook" name="post_hook" class="field mono" rows="3" style="resize: vertical;"
placeholder="# RM_JOB_STATUS in env">{{$page.HostPostHook}}</textarea>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save host-default hooks</button>
</div>
</form>
{{/* ---------- 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"
style="border-color: color-mix(in oklch, var(--bad), transparent 70%);">
<div class="flex items-start justify-between gap-6">
<div class="flex-1">
<div class="text-[14px] font-semibold text-ink">Re-initialise repo</div>
<p class="text-pretty text-[12.5px] text-ink-mid leading-[1.6] mt-2 max-w-[580px]">
Tries to <span class="mono text-ink-mid">DELETE</span> the rest-server's copy of this repo, then runs
<span class="mono text-ink-mid">restic init</span> against the empty path. Most rest-server setups run with
<span class="mono text-ink-mid">--append-only</span> and refuse the DELETE — the future P2R-09 flow surfaces
guided cleanup steps in that case.
</p>
<p class="text-[12px] text-ink-fade leading-[1.55] mt-2">
All snapshots are lost; this host's schedule version stays the same and the agent's
<span class="mono text-ink-mid">secrets.enc</span> is reused.
</p>
</div>
<form method="post" action="/hosts/{{$host.ID}}/repo/reinit"
class="flex-none flex flex-col items-end" style="gap: 8px;"
onsubmit="return confirm('Re-initialise the repo on host &quot;{{$host.Name}}&quot;? Existing snapshots are lost if the rest-server allows the wipe; restic refuses if it sees a config file already there.');">
<input type="text" name="confirm_hostname" required autocomplete="off"
placeholder="type hostname to confirm"
class="field mono"
style="width: 240px; height: 30px; padding: 0 8px; font-size: 12px;">
<button type="submit" class="btn btn-danger btn-lg whitespace-nowrap"
{{if eq $host.Status "online"}}{{else}}disabled title="host is offline"{{end}}>Re-init repo…</button>
</form>
</div>
</div>
</div>
{{/* ---------- right rail ---------- */}}
<aside class="col-span-4">
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-3.5">Storage</h2>
<div class="panel rounded-[7px] p-5">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Repo size</div>
<div class="mono text-[20px] text-ink mt-1">{{bytes $host.RepoSizeBytes}}</div>
</div>
<div>
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Snapshots</div>
<div class="mono text-[20px] text-ink mt-1">{{comma $host.SnapshotCount}}</div>
<div class="text-[11.5px] text-ink-mute mt-0.5">across {{len $page.GroupNames}} source group{{if ne (len $page.GroupNames) 1}}s{{end}}</div>
</div>
</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">
<div class="grid items-baseline text-[13px]" style="grid-template-columns: 1fr auto auto; gap: 8px 14px;">
{{range $page.GroupNames}}
<span class="mono text-ink">{{.}}</span>
<span class="mono text-ink-mute text-right">{{index $page.SnapshotsByTag .}}</span>
<span class="mono text-ink-fade text-[11px]">snapshots</span>
{{end}}
{{if gt $page.UntaggedSnapshots 0}}
<span class="mono text-ink-fade italic">untagged</span>
<span class="mono text-ink-mute text-right">{{$page.UntaggedSnapshots}}</span>
<span class="mono text-ink-fade text-[11px]">snapshots</span>
{{end}}
</div>
</div>
{{end}}
<div class="panel rounded-[6px] px-4 py-3.5 mt-5" style="background: var(--bg);">
<div class="text-[11px] uppercase tracking-[0.08em] text-ink-fade">Untagged snapshots</div>
<p class="text-[12px] text-ink-mid mt-1.5 leading-[1.55]">
Any snapshot not tagged with one of this host's source groups is left alone — forget never touches it. Useful if someone runs
<span class="mono text-ink-mid">restic backup</span> outside restic-manager; nothing here will silently delete those.
</p>
</div>
</aside>
</div>
{{end}}