c6237d4004
Closes the schedule foundations slice — operator can now drive the
plumbing P2-01..03 landed without touching the JSON API.
* New routes:
- GET /hosts/{id}/schedules (list)
- GET /hosts/{id}/schedules/new (create form)
- POST /hosts/{id}/schedules/new (create)
- GET /hosts/{id}/schedules/{sid}/edit (edit form)
- POST /hosts/{id}/schedules/{sid}/edit (update)
- POST /hosts/{id}/schedules/{sid}/delete (delete, confirm-then-redirect)
* List view (web/templates/pages/schedules_list.html):
status, cron, paths, retention summary, tags, edit/delete buttons.
Header shows "version N · agent in sync" or "agent at vM" when the
push hasn't been ack'd yet — backed by host_schedule_version +
applied_schedule_version. Empty-state CTA points at /schedules/new.
* Create/edit form (web/templates/pages/schedule_edit.html, shared):
cron expression with five quick-pick presets (daily 3am / every 6h
/ @hourly / weekly Sun / monthly 1st), paths textarea (one per
line), excludes textarea, tags (comma-separated), retention as six
numeric fields (mirrors restic's --keep-* flags one-for-one),
bandwidth caps, enabled toggle. Side panel explains the
reconciliation flow so the operator knows what saving actually
does. Validation errors re-render with operator's input intact.
* internal/server/http/ui_schedules.go owns the handlers; reuses
the same validateSchedule + pushScheduleSetAsync used by the JSON
API path. Each save audit-logs schedule.created / schedule.updated
/ schedule.deleted (matching the JSON API actions).
* store.RetentionPolicy gains a Summary() method ("last=7, d=14,
w=4" or "—"). Used by the list view's table cell so templates
don't have to do any conditional retention rendering.
* Two new template helpers: list (string varargs → []string, used
for the cron preset row) and joinComma (sibling to joinDot for
the rare list that wants commas). RetentionPolicy.Summary covers
the schedule-list case but the helpers are general.
* host_detail.html secondary tabs row converted from inert <div>s
into <a> links. Snapshots active by default; Schedules now points
at the new page. Jobs/Repo/Settings remain inert until their
P2 owners ship.
Hooks UI deferred to P2-15 (lands with the hook execution path).
Single-kind UI (backup only) by design — other kinds get a UI when
their job dispatch lands in P2-05..08.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
10 KiB
HTML
213 lines
10 KiB
HTML
{{define "title"}}{{.Title}}{{end}}
|
|
|
|
{{define "content"}}
|
|
{{$page := .Page}}
|
|
{{$host := $page.Host}}
|
|
<div class="max-w-[1280px] mx-auto px-8 pt-7">
|
|
|
|
<div class="crumbs"><a href="/">Dashboard</a><span class="sep">/</span><span class="text-ink-mid">{{$host.Name}}</span></div>
|
|
|
|
{{/* ---------- header ---------- */}}
|
|
<div class="flex items-start justify-between mt-3.5">
|
|
<div>
|
|
<div class="flex items-center gap-3">
|
|
{{if eq $host.Status "online"}}
|
|
<span class="dot dot-online{{if $host.CurrentJobID}} pulse{{end}}"></span>
|
|
{{else if eq $host.Status "degraded"}}
|
|
<span class="dot dot-degraded"></span>
|
|
{{else if eq $host.Status "offline"}}
|
|
<span class="dot dot-offline"></span>
|
|
{{else}}
|
|
<span class="dot dot-failed"></span>
|
|
{{end}}
|
|
<h1 class="mono text-[26px] font-medium tracking-[0.005em] text-ink">{{$host.Name}}</h1>
|
|
<div class="flex gap-1.5">{{range $host.Tags}}<span class="tag">{{.}}</span>{{end}}</div>
|
|
</div>
|
|
<div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute">
|
|
<span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
|
|
<span class="text-ink-fade">·</span>
|
|
<span>agent <span class="mono text-ink-mid">{{if $host.AgentVersion}}{{$host.AgentVersion}}{{else}}—{{end}}</span></span>
|
|
<span class="text-ink-fade">·</span>
|
|
<span>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span>
|
|
<span class="text-ink-fade">·</span>
|
|
{{if eq $host.Status "offline"}}
|
|
<span>last seen <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
|
|
{{else}}
|
|
<span>online · last heartbeat <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{{if eq $host.Status "offline"}}
|
|
<button class="btn" disabled title="agent is offline">Run backup now</button>
|
|
{{else if not $host.RepoInitialisedAt}}
|
|
<button class="btn btn-danger"
|
|
hx-post="/hosts/{{$host.ID}}/init-repo"
|
|
hx-swap="none"
|
|
hx-disabled-elt="this"
|
|
title="restic repo not yet initialised — run this once before the first backup">Initialise repo</button>
|
|
{{else}}
|
|
<button class="btn btn-primary"
|
|
hx-post="/hosts/{{$host.ID}}/run-backup"
|
|
hx-swap="none"
|
|
hx-disabled-elt="this">Run backup now</button>
|
|
{{end}}
|
|
<button class="btn">Edit credentials</button>
|
|
<button class="btn btn-ghost text-base px-2.5">⋯</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* ---------- vitals strip ---------- */}}
|
|
<div class="grid grid-cols-12 gap-6 mt-6 py-5 border-y border-line-soft">
|
|
<div class="col-span-3">
|
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Last backup</div>
|
|
<div class="mono text-[18px] text-ink mt-1">
|
|
{{if eq (deref $host.LastBackupStatus) "succeeded"}}
|
|
<span class="text-ok">succeeded</span>
|
|
{{else if eq (deref $host.LastBackupStatus) "failed"}}
|
|
<span class="text-bad">failed</span>
|
|
{{else if eq (deref $host.LastBackupStatus) "cancelled"}}
|
|
<span class="text-warn">cancelled</span>
|
|
{{else}}
|
|
<span class="text-ink-fade italic">never run</span>
|
|
{{end}}
|
|
{{if $host.LastBackupAt}} · {{relTime $host.LastBackupAt}}{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="col-span-3">
|
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Repo size</div>
|
|
<div class="mono text-[18px] text-ink mt-1">{{bytes $host.RepoSizeBytes}}</div>
|
|
</div>
|
|
<div class="col-span-3">
|
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Snapshots</div>
|
|
<div class="mono text-[18px] text-ink mt-1">{{comma $host.SnapshotCount}}</div>
|
|
</div>
|
|
<div class="col-span-3">
|
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em]">Open alerts</div>
|
|
<div class="mono text-[18px] mt-1 {{if gt $host.OpenAlertCount 0}}text-bad{{else}}text-ok{{end}}">
|
|
{{if eq $host.OpenAlertCount 0}}0 · all clear{{else}}{{$host.OpenAlertCount}} · review →{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* ---------- secondary tabs ---------- */}}
|
|
<div class="flex items-end mt-1.5">
|
|
<a class="sub-tab active" href="/hosts/{{$host.ID}}">Snapshots <span class="mono text-ink-fade text-[11px] ml-1">{{comma $host.SnapshotCount}}</span></a>
|
|
<a class="sub-tab" href="/hosts/{{$host.ID}}/schedules">Schedules</a>
|
|
<div class="sub-tab">Jobs</div>
|
|
<div class="sub-tab">Repo</div>
|
|
<div class="sub-tab">Settings</div>
|
|
</div>
|
|
|
|
{{/* ---------- snapshots tab ---------- */}}
|
|
<div class="grid grid-cols-12 gap-6 pt-6 pb-14 items-start">
|
|
|
|
<div class="col-span-9">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-3">
|
|
<h2 class="text-[13px] font-semibold tracking-[0.01em]">Snapshots</h2>
|
|
<div class="text-xs text-ink-fade">showing {{$page.SnapshotsShown}} of {{comma $host.SnapshotCount}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel rounded-[7px] overflow-hidden">
|
|
|
|
{{if eq (len $page.Snapshots) 0}}
|
|
<div class="empty-state" style="border: none; background: var(--panel);">
|
|
<h3 class="text-base font-medium tracking-[-0.005em]">No snapshots yet.</h3>
|
|
<p class="text-pretty text-ink-mute text-[13px] mt-2 mx-auto max-w-[440px] leading-[1.65]">
|
|
Once a backup completes, the agent will refresh this list automatically.
|
|
</p>
|
|
{{if ne $host.Status "offline"}}
|
|
<div class="mt-5">
|
|
{{if not $host.RepoInitialisedAt}}
|
|
<button class="btn btn-danger"
|
|
hx-post="/hosts/{{$host.ID}}/init-repo"
|
|
hx-swap="none"
|
|
hx-disabled-elt="this">Initialise repo</button>
|
|
{{else}}
|
|
<button class="btn btn-primary"
|
|
hx-post="/hosts/{{$host.ID}}/run-backup"
|
|
hx-swap="none"
|
|
hx-disabled-elt="this">Run now</button>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}
|
|
<div class="hairline grid items-baseline px-4 py-2.5 text-[11px] text-ink-fade uppercase tracking-[0.08em]"
|
|
style="grid-template-columns: 0.8fr 1fr 2fr 0.7fr 0.7fr 0.7fr; column-gap: 18px;">
|
|
<div>Snapshot id</div>
|
|
<div>Time</div>
|
|
<div>Paths</div>
|
|
<div class="text-right">Size</div>
|
|
<div class="text-right">Files</div>
|
|
<div></div>
|
|
</div>
|
|
|
|
{{range $i, $s := $page.Snapshots}}
|
|
<div class="grid items-center px-4 py-2.5 text-[13px] hairline"
|
|
style="grid-template-columns: 0.8fr 1fr 2fr 0.7fr 0.7fr 0.7fr; column-gap: 18px;">
|
|
<div class="mono text-ink font-medium">{{$s.ShortID}}</div>
|
|
<div class="mono text-ink-mid text-[12px]">{{absTime $s.Time}}</div>
|
|
<div class="mono text-ink-mid text-[12px] truncate" title="{{joinDot $s.Paths}}">{{joinDot $s.Paths}}</div>
|
|
<div class="text-right mono text-ink">{{bytes $s.SizeBytes}}</div>
|
|
<div class="text-right mono text-ink-mid">
|
|
{{if eq $s.FileCount 0}}<span class="text-ink-fade">—</span>{{else}}{{comma $s.FileCount}}{{end}}
|
|
</div>
|
|
<div class="text-right">
|
|
<button class="btn btn-ghost" disabled title="restore wizard lands in P3">Restore →</button>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
{{if eq $host.SnapshotCount 0}}
|
|
{{else if gt $host.SnapshotCount $page.SnapshotsShown}}
|
|
<div class="text-xs text-ink-mute mt-3 px-1">showing the {{$page.SnapshotsShown}} most-recent snapshots — full pagination lands later</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{/* ---------- right rail ---------- */}}
|
|
<aside class="col-span-3 flex flex-col gap-4">
|
|
|
|
<div class="panel rounded-[7px] px-4 py-3.5">
|
|
<div class="text-[11px] text-ink-fade uppercase tracking-[0.1em] mb-2.5">Run-now</div>
|
|
<div class="flex flex-col gap-1.5">
|
|
{{if not $host.RepoInitialisedAt}}
|
|
<button class="btn justify-start w-full text-bad font-medium {{if eq $host.Status "offline"}}opacity-50 cursor-not-allowed pointer-events-none{{end}}"
|
|
hx-post="/hosts/{{$host.ID}}/init-repo"
|
|
hx-swap="none"
|
|
hx-disabled-elt="this"
|
|
title="restic repo not yet initialised — click to run `restic init` once">init</button>
|
|
{{end}}
|
|
<button class="btn justify-start w-full {{if or (eq $host.Status "offline") (not $host.RepoInitialisedAt)}}opacity-50 cursor-not-allowed pointer-events-none{{end}}"
|
|
hx-post="/hosts/{{$host.ID}}/run-backup"
|
|
hx-swap="none"
|
|
hx-disabled-elt="this"
|
|
{{if not $host.RepoInitialisedAt}}title="initialise the repo first"{{end}}>backup</button>
|
|
<button class="btn justify-start w-full" disabled title="lands with P2-05">forget <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button>
|
|
<button class="btn justify-start w-full" disabled title="lands with P2-06">prune <span class="text-[10px] text-ink-fade ml-1.5">admin</span></button>
|
|
<button class="btn justify-start w-full" disabled title="lands with P2-07">check <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button>
|
|
<button class="btn justify-start w-full" disabled title="lands with P2-08">unlock <span class="text-[10px] text-ink-fade ml-1.5">P2</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel rounded-[7px] px-4 py-3.5">
|
|
<div class="text-[11px] text-bad uppercase tracking-[0.1em] font-semibold mb-2.5">Danger zone</div>
|
|
<p class="text-pretty text-[12px] text-ink-mute leading-[1.55] mb-3">
|
|
Removes the host record. The repo data on the rest-server is left intact —
|
|
you delete that yourself.
|
|
</p>
|
|
<button class="btn btn-danger w-full justify-center" disabled title="lands later in Phase 1">Remove host…</button>
|
|
</div>
|
|
|
|
</aside>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
{{end}}
|