P1-25: host detail page (snapshots tab default)
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user