Files
restic-manager/internal/server/ui/ui.go
T
steve ee16bc7ce7
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
P1-24: live dashboard — fleet summary tiles + host table
Server-rendered HTML view backed by:
  - new store.FleetSummary aggregating host counts + repo bytes +
    snapshot total + open alerts + last-24h job rollup in two queries.
  - GET /api/hosts (JSON list of hosts in the dashboard projection).
  - GET /api/fleet/summary (JSON aggregate, same shape as above).

The HTML page (web/templates/pages/dashboard.html) renders the four
summary tiles + host table directly from store data — no separate
fetch. Per-row state colour comes from .host-row.{degraded,failed,
offline} which paint a 3px left edge so problem hosts are scannable
without reading. HTMX is loaded into the base layout so per-row
"Run now" buttons can hx-post to /hosts/{id}/run-backup, a thin
HTML wrapper that funnels into a new dispatchJob helper shared
with the JSON /api/hosts/{id}/jobs endpoint.

Empty state (zero hosts) collapses to the "no hosts yet" prompt
with the + Add host CTA — matches the v1 mockup.

Template helpers (internal/server/ui/funcs.go) added for byte
formatting (412 GB / 3.7 TB), relative time (3m ago / 2d ago), and
comma grouping (1,847). Pure Go, no template-magic dependency.

Browser-verified end-to-end with seeded fixture data: five hosts
across all four states render with correct dots, accents, last-
backup pills, sizes, snapshot counts, alerts, tags, and the right
action button (Run now / Retry / Run first / View → / offline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:29:11 +01:00

138 lines
4.1 KiB
Go

// Package ui renders the HTMX/Tailwind frontend from server-side
// html/templates. Templates live under web/templates and are
// embedded into the binary via web.FS.
//
// Lifecycle:
// - At startup, parse every layout, partial, and page into a
// single *template.Template tree.
// - To render a page, call Render(w, "<page>", ViewData{...}).
// Render walks the page's template definitions (which override
// the {{block "content"}} / {{block "title"}} placeholders in
// the chosen layout) and writes the result.
package ui
import (
"fmt"
"html/template"
"io"
"io/fs"
"path"
"strings"
"gitea.dcglab.co.uk/steve/restic-manager/web"
)
// ViewData is the common frame every template renders against.
// Page handlers populate Page with their own concrete shape and the
// renderer wraps it.
type ViewData struct {
// Title is rendered in <title>; layouts/base default to
// "restic-manager" if absent. Pages that {{define "title"}} win.
Title string
// User is the currently signed-in user, or nil if the session
// cookie is missing/invalid. The nav uses this to decide
// whether to show "Sign out" or "Sign in".
User *User
// Active is the slug of the currently active primary nav tab
// ("dashboard" / "repos" / "alerts" / "audit" / "settings").
// The nav partial highlights the matching tab.
Active string
// OpenAlerts is shown next to the Alerts tab when > 0.
OpenAlerts int
// Version is the build version printed in the top-right of the
// chrome. Falls back to "dev" if the binary wasn't built with
// -ldflags -X main.version=…
Version string
// Username pre-fills the login form on a re-render after a bad
// attempt. Login-only.
Username string
// Error is a single banner-level error string. Login uses it
// today; other pages can adopt the same field.
Error string
// Page carries page-specific data. Concrete type is the page's
// own struct.
Page any
}
// User is the minimal projection of the authenticated user that the
// templates need. Avoids leaking store internals into the view.
type User struct {
ID string
Username string
Role string
}
// Renderer holds the parsed templates.
type Renderer struct {
pages map[string]*template.Template
}
// New parses every layout, partial, and page from web.FS into one
// template tree per page. Pages associate with a layout via the
// path under templates/pages/: anything at templates/pages/login.html
// wraps in templates/layouts/chromeless.html, everything else wraps
// in templates/layouts/base.html.
//
// Returns an error if any template fails to parse — fail loud at
// startup, not at request time.
func New() (*Renderer, error) {
// All layouts + partials are shared.
commonPaths := []string{
"templates/layouts/base.html",
"templates/layouts/chromeless.html",
"templates/partials/nav.html",
"templates/partials/host_row.html",
}
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
if err != nil {
return nil, fmt.Errorf("ui: glob pages: %w", err)
}
if len(pageEntries) == 0 {
return nil, fmt.Errorf("ui: no pages found under templates/pages/")
}
r := &Renderer{pages: make(map[string]*template.Template, len(pageEntries))}
for _, p := range pageEntries {
base := strings.TrimSuffix(path.Base(p), ".html")
t, err := template.New(base).Funcs(funcMap()).
ParseFS(web.FS, append(append([]string{}, commonPaths...), p)...)
if err != nil {
return nil, fmt.Errorf("ui: parse %s: %w", p, err)
}
r.pages[base] = t
}
return r, nil
}
// Render writes the named page (e.g. "dashboard", "login") to w,
// wrapped in the appropriate layout. layoutFor decides which.
func (r *Renderer) Render(w io.Writer, page string, data ViewData) error {
t, ok := r.pages[page]
if !ok {
return fmt.Errorf("ui: unknown page %q", page)
}
if data.Version == "" {
data.Version = "dev"
}
return t.ExecuteTemplate(w, layoutFor(page), data)
}
// layoutFor picks the layout name for a page. Login + bootstrap go
// chrome-less; everything else uses the standard navigation chrome.
func layoutFor(page string) string {
switch page {
case "login", "bootstrap":
return "chromeless"
default:
return "base"
}
}