P1-25: host detail page (snapshots tab default)
CI / Test (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (windows/amd64) (push) Has been cancelled
CI / Build (linux/amd64) (push) Has been cancelled
CI / Build (linux/arm64) (push) Has been cancelled

GET /hosts/{id} renders the v1 host detail layout:

  - persistent header: status dot (pulse if a job is in flight),
    monospace name, tags, plus a metadata strip (os/arch, agent
    version, restic version, "last seen Xs ago" or "online · last
    heartbeat …").
  - vitals strip: four tiles for last backup (status + relative
    time), repo size, snapshot count, open alerts.
  - sub-tabs: Snapshots is active; Jobs / Repo / Settings are
    visible but inert until P2.
  - snapshot table: short id, time (absolute), paths joined with
    " · ", size, file count, restore button (disabled — wires up
    in P3).
  - right rail: run-now stack (backup live, forget/prune/check/
    unlock disabled with the Phase tag), danger-zone remove panel
    (also disabled for now).

Empty state: when a host has no snapshots yet, the table replaces
itself with a "no snapshots yet" prompt that includes the run-now
button (provided the agent is online).

Pagination cap of 50 most-recent snapshots; full pagination lands
when fleet sizes demand it.

Template helpers grew: comma() now accepts int / int32 / int64 so
templates don't fight Go's type inference; joinDot() concatenates
a []string with " · "; absTime() formats time.Time as
YYYY-MM-DD HH:MM:SS; the existing relTime() already accepts T or
*T after P1-27.

Browser-verified end-to-end with seeded fixture data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:20:21 +01:00
parent dad8c7fe99
commit 26a2b85e13
6 changed files with 271 additions and 4 deletions
+2
View File
@@ -136,6 +136,8 @@ func (s *Server) routes(r chi.Router) {
// Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
// Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail)
}
}
+56
View File
@@ -261,6 +261,62 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
}
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
Host store.Host
Snapshots []store.Snapshot
// SnapshotsShown is the number rendered (we cap at ~50 for the
// first slice; pagination lands when it matters).
SnapshotsShown int
}
// handleUIHostDetail is the host detail page (snapshots tab by default).
// Auth-gated. 404 if the host id is unknown.
func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
stdhttp.NotFound(w, r)
return
}
host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui host detail: get host", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
if err != nil {
slog.Error("ui host detail: list snapshots", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
const cap = 50
shown := snaps
if len(shown) > cap {
shown = shown[:cap]
}
view := s.baseView(u, "dashboard")
view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{
Host: *host,
Snapshots: shown,
SnapshotsShown: len(shown),
}
if err := s.deps.UI.Render(w, "host_detail", view); err != nil {
slog.Error("ui: render host_detail", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// publicURL is what the operator should paste into the install
// command. Prefers RM_BASE_URL (set by the operator's reverse
// proxy config) and falls back to scheme + Host of the inbound
+21 -2
View File
@@ -18,6 +18,13 @@ func funcMap() template.FuncMap {
"comma": formatComma,
"deref": derefStr,
"timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() },
"joinDot": func(parts []string) string { return strings.Join(parts, " · ") },
"absTime": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format("2006-01-02 15:04:05")
},
}
}
@@ -112,8 +119,20 @@ func formatRelTime(v any) string {
// formatComma renders 1847 as "1,847". Used for snapshot counts and
// any other count that benefits from grouping at this scale.
func formatComma(n int) string {
s := strconv.Itoa(n)
// Accepts int / int64 / int32 — anything else returns "—".
func formatComma(v any) string {
var n int64
switch x := v.(type) {
case int:
n = int64(x)
case int32:
n = int64(x)
case int64:
n = x
default:
return "—"
}
s := strconv.FormatInt(n, 10)
if n < 1000 && n > -1000 {
return s
}
+1 -1
View File
@@ -57,7 +57,7 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
- [x] **P1-23** (M) Base layout, login page, session-aware nav
- [x] **P1-24** (M) Dashboard: fleet summary tiles + host table (status dot + row accent + os/arch + last backup + repo size + snapshots + alerts + tags + run-now). Backed by `GET /api/hosts` + `GET /api/fleet/summary` (JSON) and a server-rendered HTML view. Empty state hands the operator the install command. HTMX `Run now` button posts to `/hosts/{id}/run-backup`.
- [ ] **P1-25** (M) Host detail page: snapshots tab + run-now button
- [x] **P1-25** (M) Host detail page (`/hosts/{id}`): persistent header (status dot + mono name + tags + OS/arch/agent/restic/last-seen), vitals strip (last backup / repo size / snapshots / open alerts), sub-tabs (Snapshots active; Jobs/Repo/Settings tabs visible but inert until P2), snapshot table (cap 50, pagination later), right-rail run-now stack (backup live; forget/prune/check/unlock disabled with P2 hints) and a danger-zone delete panel.
- [ ] **P1-26** (M) Live job log viewer (WS-driven, auto-scroll, cancel button)
- [~] **P1-27** (M) "Add host" flow: form takes hostname + repo URL/username/password, mints token (TTL 1h), re-renders the same page in result-state with the install command (`RM_SERVER` + `RM_TOKEN` filled in), copy button, and an awaiting-agent panel. Encrypted repo creds ride on the token row (P1-32) and get pushed to the agent on first WS connect (P1-33). **Deferred:** one-click "download preconfigured installer" `install-<hostname>.sh` (cf. UrBackup Internet-mode push installer) — copy-paste covers it for v1.
- [x] **P1-28** (S) Tailwind build via `tailwindcss` standalone binary (no Node) — Makefile downloads pinned v3.4.17 into `bin/tailwindcss`, builds `web/styles/input.css``web/static/css/styles.css`, embedded into the binary via `web.FS`. `make build` runs Tailwind first.
File diff suppressed because one or more lines are too long
+190
View File
@@ -0,0 +1,190 @@
{{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 ne $host.Status "offline"}}
<button class="btn btn-primary"
hx-post="/hosts/{{$host.ID}}/run-backup"
hx-swap="none"
hx-disabled-elt="this">Run backup now</button>
{{else}}
<button class="btn" disabled title="agent is offline">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">
<div class="sub-tab active">Snapshots <span class="mono text-ink-fade text-[11px] ml-1">{{comma $host.SnapshotCount}}</span></div>
<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">
<button class="btn btn-primary"
hx-post="/hosts/{{$host.ID}}/run-backup"
hx-swap="none"
hx-disabled-elt="this">Run first backup</button>
</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">
<button class="btn justify-start w-full {{if eq $host.Status "offline"}}opacity-50 cursor-not-allowed pointer-events-none{{end}}"
hx-post="/hosts/{{$host.ID}}/run-backup"
hx-swap="none"
hx-disabled-elt="this">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}}