ee3ee241ea
Cohesive batch from a smoke-test session against a real rest-server.
Themed bullets:
* Agent runs as root, sandboxed via systemd. CapabilityBoundingSet
drops to CAP_DAC_READ_SEARCH + restore caps; ProtectSystem=strict
with ReadWritePaths confined to /etc + /var/lib/restic-manager;
NoNewPrivileges blocks escalation. Install script no longer
creates a service user. spec.md §4.2 / §14.1 / §14.3 explain the
rationale (matches UrBackup / Veeam / Bareos defaults; trying to
back up "everything" as an unprivileged user creates silent skips
on /home, /root, /var/lib/* with no upside vs the threat model
the agent already implies).
* Init-repo end-to-end. New JobKind="init" wired through agent
runner, restic.Env.RunInit, server dispatcher, and a UI button
(red "Initialise repo" in the run-now panel). hosts.repo_initialised_at
flips on init success, on backup success, or on a non-empty
snapshots.report. The "Run now" / "Init" / "Retry" branching now
drives both the dashboard host row and the host-detail panel.
Migrations 0004 (column), 0005 (jobs.kind CHECK widened — using
the safe create-new-then-rename pattern; first version corrupted
job_logs.job_id FK), 0006 (cleans up job_logs FK on already-
affected DBs).
* rest-server creds embedded at exec time only. restic.Env gains
RepoUsername; mergeRestCreds() builds the user:pass@-prefixed URL
inside envSlice() and never assigns it back to the struct, so
nothing slog-able ever sees the cleartext form. RedactURL helper
for any future surface that needs to log a URL safely. Both
helpers tested.
* Add-host UX. Repo password is now optional — server mints a
24-byte URL-safe random one and surfaces it once, alongside an
htpasswd snippet ("echo PASS | htpasswd -B -i ... USERNAME") so
the operator pastes one command on the rest-server host and one
on the endpoint. Result page also links the install snippet at
/install/install.sh (was /install.sh — 404'd before) and pipes
to bash (not sh — script uses set -o pipefail and other
bashisms; on Debian/Ubuntu sh is dash).
* Late-subscriber race in JobHub. A fast-failing job could finish
(DB write + Broadcast) before the browser's HX-Redirect → page
load → WS-connect path completed, so the JS sat forever waiting
on a job.finished that already passed. JobHub split into
Register + Send + Run; handleJobStream now subscribes first,
re-fetches the job, and sends a synthetic job.finished if the
state is already terminal.
* HTMX error visibility. New toast partial listens to
htmx:responseError and surfaces the response body as a
bottom-right toast — every server-side validation error now
becomes visible without per-handler JS wiring. Also handles
custom rm:toast events for future server-pushed notifications
via the HX-Trigger header. Themed via existing CSS vars.
* Dashboard rows are now whole-row clickable to host detail
(CSS card-link pattern: absolute-positioned anchor + .row-action
z-index restoration so the action button stays clickable).
"View →" on a running job links to /jobs/<id> rather than
/hosts/<id> since the row click already covers the host page.
* "Run first" / "Run first backup" → "Run now" everywhere for
consistency.
* runbook (docs/e2e-smoke.md) updated — live-log streaming step
now reflects P1-26; mentions the browser-driven Run-now flow.
* _diag/dump-creds — moved out of cmd/ so go build doesn't pick
it up; .gitignore now excludes /_diag/ entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.1 KiB
Go
139 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",
|
|
"templates/partials/toast.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"
|
|
}
|
|
}
|