Files
restic-manager/internal/server/http/ui_handlers.go
T
steve e6729a5a3d P1-26: live job log viewer + WS browser fan-out hub
Closes the P1-21 remainder.

internal/server/ws/jobhub.go — new JobHub. Per-job_id set of
subscribers; each gets a 64-deep buffered channel with a writer
goroutine. Broadcast is non-blocking: if a subscriber is slow,
its channel fills and messages are dropped for that subscriber
only — the agent's read loop is never blocked by a stuck browser.

The agent dispatchAgentMessage path mirrors job.started /
job.progress / log.stream / job.finished envelopes onto the hub
in addition to its existing persistence work. The wire shape is
the same end-to-end, so client-side JS switches on env.type the
same way Go code does.

GET /api/jobs/{id}/stream is the browser endpoint. Auth via
session cookie (HTTP layer); upgrade; subscribe; pump until
context closes.

GET /jobs/{id} renders the live log page. Three states (queued/
running/succeeded/failed) drive the header pill, the progress
bar block, the failure summary panel, and the action button
(Cancel job while running, Back to host afterwards). Already-
persisted log lines are server-rendered on initial load; new
lines arrive over the WS and append to #log-stream. Auto-scrolls
unless the user scrolls up (a "⇢ Follow" pill re-attaches).
On job.finished the page reloads after 600ms to pick up the
final-state header rendered server-side.

POST /hosts/{id}/run-backup now sets HX-Redirect → /jobs/{job_id}
on success so HTMX lands the operator straight on the live log.
For non-HTMX callers (curl / plain form post) it 303s to the
same target.

store.ListJobLogs returns persisted log lines for initial render
on page load.

Browser-verified end-to-end: enrol → run a real backup against a
sibling restic/rest-server → live progress + 11 log lines stream
in → succeeded pill + final stats land after page reload.

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

521 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"errors"
"io/fs"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"github.com/coder/websocket"
"github.com/go-chi/chi/v5"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
"gitea.dcglab.co.uk/steve/restic-manager/web"
)
// ----- static assets (Tailwind CSS, future favicon, etc) -------------
// staticHandler serves files embedded under web/static/ at /static/*.
// Returns 404 for anything missing rather than the fs default 500.
func staticHandler() stdhttp.Handler {
sub, err := fs.Sub(web.FS, "static")
if err != nil {
// Embed.FS panics live at compile time; if Sub fails the binary
// is genuinely broken — surface it loudly.
panic("web: static subtree missing: " + err.Error())
}
return stdhttp.StripPrefix("/static/", stdhttp.FileServer(stdhttp.FS(sub)))
}
// ----- session helpers ------------------------------------------------
// sessionUser resolves the request's session cookie to a User, or
// (nil, nil) if the cookie is missing/expired/invalid. A non-nil
// error means an underlying store failure; treat that as 500.
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, nil
}
sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
if err != nil {
// Treat "not found" / "expired" as "no session", not as fatal.
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
return nil, err
}
u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
return nil, err
}
return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
}
// requireUIUser resolves the session and 303-redirects to /login if
// there isn't one. Returns nil + emits the redirect when unauthed.
// (HTML twin of jobs.go's API-style requireUser, which returns 401.)
func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui.User {
u, err := s.sessionUser(r)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return nil
}
if u == nil {
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
return nil
}
return u
}
// baseView populates the fields the nav partial needs on every
// authenticated page.
func (s *Server) baseView(u *ui.User, active string) ui.ViewData {
return ui.ViewData{
User: u,
Active: active,
Version: s.version(),
}
}
// version returns the binary's build version — passed in via Deps so
// cmd/server's `var version` ends up here.
func (s *Server) version() string {
if s.deps.Version != "" {
return s.deps.Version
}
return "dev"
}
// ----- handlers -------------------------------------------------------
// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
Hosts []store.Host
HostCount int
Summary store.FleetSummary
}
// handleUIDashboard is the root page. Auth-gated; falls through to
// /login if there is no session.
func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hosts, err := s.deps.Store.ListHosts(r.Context())
if err != nil {
slog.Error("ui dashboard: list hosts", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
summary, err := s.deps.Store.FleetSummary(r.Context())
if err != nil {
slog.Error("ui dashboard: fleet summary", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
view := s.baseView(u, "dashboard")
view.OpenAlerts = summary.OpenAlerts
view.Page = dashboardPage{
Hosts: hosts,
HostCount: len(hosts),
Summary: summary,
}
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIRunBackup is the form-submit twin of POST /api/hosts/{id}/jobs
// that the dashboard / host-detail "Run now" buttons call via
// hx-post. On success it sets HX-Redirect → /jobs/{job_id} so the
// operator lands on the live log viewer for the job they just
// kicked off.
func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest)
return
}
storeUser, _, err := s.userByID(r, u.ID)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, nil)
if code != "" {
stdhttp.Error(w, msg, status)
return
}
// HTMX (with hx-post + hx-swap=none) doesn't honour HX-Redirect
// when the response itself is a 3xx — fetch follows the redirect
// first and the header is lost. Branch on the HX-Request marker
// so HTMX gets a 200 + HX-Redirect (client-side window.location
// hop), while plain form-post / curl callers get the 303.
target := "/jobs/" + res.JobID
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", target)
w.WriteHeader(stdhttp.StatusOK)
return
}
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
}
// addHostPage carries the form state into the Add host template.
// In State A (form), Token is empty. In State B (result), Token is
// populated and the template renders the install command.
type addHostPage struct {
// Form fields — pre-populate the form on a re-render after a
// validation error.
Hostname string
Tags string
RepoURL string
RepoUsername string
// Server URL the operator should paste into the install
// command. Resolved from RM_BASE_URL falling back to the
// request's Host header.
ServerURL string
// Banner-level error shown above the form.
Error string
// Result state. When Token != "", the template renders the
// install command panel instead of the form.
Token string
ExpiresAt time.Time
}
// handleUIAddHostGet renders the empty Add host form.
func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
view := s.baseView(u, "dashboard")
view.Title = "Add host · restic-manager"
view.Page = addHostPage{ServerURL: s.publicURL(r)}
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIAddHostPost validates the form, mints the enrolment token
// (with encrypted repo creds), and re-renders the same page in
// "result" state showing the install command.
func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
page := addHostPage{
Hostname: strings.TrimSpace(r.PostForm.Get("hostname")),
Tags: strings.TrimSpace(r.PostForm.Get("tags")),
RepoURL: strings.TrimSpace(r.PostForm.Get("repo_url")),
RepoUsername: strings.TrimSpace(r.PostForm.Get("repo_username")),
ServerURL: s.publicURL(r),
}
repoPassword := r.PostForm.Get("repo_password")
if page.Hostname == "" {
page.Error = "Hostname is required."
} else if page.RepoURL == "" || repoPassword == "" {
page.Error = "Repo URL and password are both required so the agent can back up the moment it comes online."
}
if page.Error == "" {
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword)
switch err {
case nil:
page.Token = token
page.ExpiresAt = expires
case errMissingRepoCreds:
page.Error = "Repo URL and password are both required."
default:
slog.Error("ui add_host: mint token", "err", err)
page.Error = "Couldnt mint a token — see the server log for details."
}
}
view := s.baseView(u, "dashboard")
view.Title = "Add host · restic-manager"
view.Page = page
status := stdhttp.StatusOK
if page.Error != "" {
status = stdhttp.StatusUnprocessableEntity
} else {
status = stdhttp.StatusCreated
}
w.WriteHeader(status)
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
}
}
// 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
// request — useful for local smoke without a proxy.
func (s *Server) publicURL(r *stdhttp.Request) string {
if s.deps.Cfg.BaseURL != "" {
return strings.TrimRight(s.deps.Cfg.BaseURL, "/")
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// jobDetailPage carries everything the live-log template renders.
type jobDetailPage struct {
Job store.Job
Host store.Host
Logs []store.JobLogLine
NextSeq int64
IsActive bool // true while status is queued|running
}
// handleUIJobDetail renders the live job log view (snapshot of any
// already-persisted log lines + an empty stream container the JS
// fills via the WS).
func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
jobID := chi.URLParam(r, "id")
if jobID == "" {
stdhttp.NotFound(w, r)
return
}
job, err := s.deps.Store.GetJob(r.Context(), jobID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
host, err := s.deps.Store.GetHost(r.Context(), job.HostID)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
logs, err := s.deps.Store.ListJobLogs(r.Context(), jobID, 0, 0)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
var nextSeq int64
if n := len(logs); n > 0 {
nextSeq = logs[n-1].Seq
}
view := s.baseView(u, "dashboard")
view.Title = job.Kind + " · " + host.Name + " · restic-manager"
view.Page = jobDetailPage{
Job: *job,
Host: *host,
Logs: logs,
NextSeq: nextSeq,
IsActive: job.Status == "queued" || job.Status == "running",
}
if err := s.deps.UI.Render(w, "job_detail", view); err != nil {
slog.Error("ui: render job_detail", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleJobStream is the browser-side WS endpoint. Auth is via the
// session cookie (the HTTP layer does the lookup before upgrading).
// On connect we subscribe to JobHub for the given job_id; the
// subscriber goroutine pumps fan-out messages to the client until
// the job finishes or the browser navigates away.
//
// Messages on the wire are the same api.Envelope shape as on the
// agent side, so the client-side JS can switch on env.type the
// same way our Go code does.
func (s *Server) handleJobStream(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u, _ := s.sessionUser(r); u == nil {
stdhttp.Error(w, "unauthorized", stdhttp.StatusUnauthorized)
return
}
jobID := chi.URLParam(r, "id")
if jobID == "" {
stdhttp.Error(w, "missing job id", stdhttp.StatusBadRequest)
return
}
if _, err := s.deps.Store.GetJob(r.Context(), jobID); err != nil {
stdhttp.NotFound(w, r)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true, // Origin checks pointless for a same-origin browser hop.
})
if err != nil {
slog.Warn("ws browser accept failed", "job_id", jobID, "err", err)
return
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
// Wrap so we get the same Send semantics as the agent path.
c := ws.NewConn("browser-"+jobID, conn)
s.deps.JobHub.Subscribe(r.Context(), jobID, c)
}
// userByID fetches the full store.User the UI session represents.
// Returns the user, ok-flag, error. Used by handlers that need the
// store-side row (e.g. for audit_log.user_id) rather than just the
// projected ui.User.
func (s *Server) userByID(r *stdhttp.Request, id string) (*store.User, bool, error) {
u, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, false, nil
}
return nil, false, err
}
return u, true, nil
}
// handleUILoginGet renders the login form. If the user is already
// signed in we redirect them home — login is for the unauthenticated.
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u, _ := s.sessionUser(r); u != nil {
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
return
}
view := ui.ViewData{Version: s.version()}
if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUILoginPost consumes the form, validates, mints a session,
// and either redirects to / on success or re-renders the form with
// an error banner on failure.
func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
username := r.PostForm.Get("username")
password := r.PostForm.Get("password")
if _, err := s.authenticateAndSession(w, r, username, password); err != nil {
// Re-render the form. Single generic message — see
// authenticateAndSession's note on not leaking user existence.
view := ui.ViewData{
Version: s.version(),
Username: username,
Error: "Invalid username or password.",
}
w.WriteHeader(stdhttp.StatusUnauthorized)
if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login (post-fail)", "err", err)
}
return
}
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
}
// handleUILogoutPost is the form-submit twin of /api/auth/logout. It
// drops the session cookie and redirects to /login.
func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
}
stdhttp.SetCookie(w, &stdhttp.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.deps.Cfg.CookieSecure,
SameSite: stdhttp.SameSiteLaxMode,
})
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
}