Files
restic-manager/internal/server/http/ui_handlers.go
T
steve 539b941db5 ui: snapshots SIZE/FILES tooltip when host's restic is < 0.17
Per-snapshot size + file-count come from the embedded summary block
restic added to 'snapshots --json' in 0.17 (the source comment in
internal/restic/snapshots.go incorrectly said 0.16+). Hosts running
0.16.x leave those columns blank.

- Fix the snapshots.go doc comment: '0.16+' -> '0.17+'.
- hostDetailPage carries a LegacyRestic bool computed from the host's
  reported ResticVersion via Env.AtLeastVersion(0, 17). Empty version
  also counts as legacy (conservative default).
- Template attaches title='Needs restic 0.17+ on the agent host. This
  host runs <ver>.' + cursor:help on the SIZE / FILES headers when
  the flag is true. Hosts already on 0.17+ get no tooltip and no
  extra styling.

A host upgrading restic to 0.17+ gets the columns populated on the
next backup automatically — no further code change needed.
2026-05-04 17:45:32 +01:00

871 lines
28 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 (
"crypto/rand"
"encoding/base64"
"encoding/json"
"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/restic"
"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 {
// Missing or invalid cookie just means the caller isn't logged
// in — that's a normal state, not a server error. Return
// (nil, nil) so callers can decide between "redirect to login"
// and "treat as anonymous".
return nil, nil //nolint:nilerr
}
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. Every UI page sits under the dashboard primary
// nav today; if a future page lives under a different primary nav
// tab (e.g. Settings, Audit), accept an Active arg again.
func (s *Server) baseView(u *ui.User) ui.ViewData {
return ui.ViewData{
User: u,
Active: "dashboard",
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 []dashboardHostRow
HostCount int
Summary store.FleetSummary
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
}
// dashboardHostRow carries a host plus the per-row Run-now decision
// the host_row partial needs. The decision is computed server-side
// once per render rather than recomputed in the template.
type dashboardHostRow struct {
Host store.Host
// RunAllScheduleID is the ID of the single schedule that covers
// every source group on the host. Empty when zero or 2+ schedules
// match — in that case the row shows "Open →" instead of a Run-now
// button (the operator picks per-group from the host detail).
RunAllScheduleID string
// NextRun is the next-fire time of RunAllScheduleID (when set),
// computed server-side from its cron. nil otherwise.
NextRun *time.Time
}
// pickRunAllSchedule returns the ID of the single schedule whose
// source-group set ⊇ every source group on the host. Returns "" when
// zero or 2+ such "covering" schedules exist (operator-disambiguation
// belongs on the host detail, not the dashboard one-click).
func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string {
if len(groups) == 0 || len(scheds) == 0 {
return ""
}
groupIDs := make(map[string]struct{}, len(groups))
for _, g := range groups {
groupIDs[g.ID] = struct{}{}
}
matched := ""
for _, sc := range scheds {
if !sc.Enabled {
continue
}
// Treat sc.SourceGroupIDs as a set; check it covers every group.
got := make(map[string]struct{}, len(sc.SourceGroupIDs))
for _, gid := range sc.SourceGroupIDs {
got[gid] = struct{}{}
}
covers := true
for gid := range groupIDs {
if _, ok := got[gid]; !ok {
covers = false
break
}
}
if !covers {
continue
}
if matched != "" {
// Two distinct covering schedules — ambiguous, bail out.
return ""
}
matched = sc.ID
}
return matched
}
// 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
}
// Per-host: pick the single covering schedule (if any) so the row
// can render a one-click Run-now where it's unambiguous. Two store
// calls per host — fine at fleet sizes we care about.
rows := make([]dashboardHostRow, 0, len(hosts))
for _, h := range hosts {
row := dashboardHostRow{Host: h}
groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID)
if gerr != nil {
slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr)
}
scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID)
if serr != nil {
slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr)
}
row.RunAllScheduleID = pickRunAllSchedule(scheds, groups)
if row.RunAllScheduleID != "" {
for _, sc := range scheds {
if sc.ID == row.RunAllScheduleID {
if parsed, perr := cronParser.Parse(sc.CronExpr); perr == nil {
n := parsed.Next(time.Now().UTC()).UTC()
row.NextRun = &n
}
break
}
}
}
rows = append(rows, row)
}
pending, perr := s.deps.Store.ListPendingHosts(r.Context(), time.Now().UTC())
if perr != nil {
slog.Warn("ui dashboard: list pending hosts", "err", perr)
}
view := s.baseView(u)
view.OpenAlerts = summary.OpenAlerts
view.Page = dashboardPage{
Hosts: rows,
HostCount: len(hosts),
Summary: summary,
PendingHosts: pending,
}
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// Per-host Run-now and manual Init-repo were retired by the P2 redesign.
// Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs
// automatically on the agent's first WS connect after enrolment. Both
// routes return 410 Gone so any cached browser tab gets a clear error.
func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w,
"per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run",
stdhttp.StatusGone)
}
func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w,
"manual init-repo is gone — the server auto-inits on the agent's first connect",
stdhttp.StatusGone)
}
// addHostPage carries the Add-host form state. The result-state
// (showing the install command + htpasswd snippet) lives at
// /hosts/pending/{token} and uses pendingHostPage instead, so the
// operator can refresh / bookmark / come back later — the password
// is decrypted from the still-alive token row on every render
// rather than living only in a one-shot rendered response.
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
Paths string
ServerURL string
Error string
}
// pendingHostPage is the GET /hosts/pending/{token} view. Lives
// for as long as the token does (1h ttl); once the agent enrols,
// the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct {
Token string
ServerURL string
ExpiresAt time.Time
RepoURL string
RepoUsername string
RepoPassword string
InitialPaths []string
}
// 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)
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 303-redirects to the persistent
// pending-host page. On validation errors we re-render the form
// with the operator's typed input intact and a banner.
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")),
Paths: r.PostForm.Get("paths"),
ServerURL: s.publicURL(r),
}
repoPassword := r.PostForm.Get("repo_password")
if page.Hostname == "" {
page.Error = "Hostname is required."
} else if page.RepoURL == "" {
page.Error = "Repo URL is required so the agent can back up the moment it comes online."
}
if page.Error == "" && repoPassword == "" {
gen, err := generateRepoPassword()
if err != nil {
slog.Error("ui add_host: generate repo password", "err", err)
page.Error = "Couldnt generate a password — see the server log for details."
} else {
repoPassword = gen
}
}
// Default repo username to the hostname when the operator left it
// blank. With rest-server's --private-repos this is what the URL
// path segment is expected to be anyway, and an htpasswd entry
// always needs *some* user — defaulting saves the operator from
// landing on a pending page with a half-formed snippet.
repoUsername := page.RepoUsername
if repoUsername == "" {
repoUsername = page.Hostname
}
if page.Error == "" {
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths))
switch {
case err == nil:
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
return
case errors.Is(err, 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)
view.Title = "Add host · restic-manager"
view.Page = page
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
}
}
// handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the
// agent enrols (the page redirects to /hosts/{id} once that
// happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back.
func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
rawToken := chi.URLParam(r, "token")
if rawToken == "" {
stdhttp.NotFound(w, r)
return
}
tokHash := auth.HashToken(rawToken)
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), tokHash)
if err != nil {
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
if status.ConsumedHost != nil {
stdhttp.Redirect(w, r, "/hosts/"+*status.ConsumedHost, stdhttp.StatusSeeOther)
return
}
if time.Now().After(status.ExpiresAt) {
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), tokHash)
if err != nil {
slog.Warn("ui pending: load attachments", "err", err)
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
page := pendingHostPage{
Token: rawToken,
ServerURL: s.publicURL(r),
ExpiresAt: status.ExpiresAt,
InitialPaths: att.InitialPaths,
}
if att.EncRepoCreds != "" {
plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash))
if err != nil {
slog.Error("ui pending: decrypt creds", "err", err)
} else {
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err == nil {
page.RepoURL = blob.RepoURL
page.RepoUsername = blob.RepoUsername
page.RepoPassword = blob.RepoPassword
}
}
}
view := s.baseView(u)
view.Title = "Pending host · restic-manager"
view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
slog.Error("ui: render pending_host", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIPendingAwaiting is the polled fragment that the pending-
// host page swaps in every couple of seconds to detect "agent
// connected". Returns either the still-awaiting partial (with the
// HTMX poll trigger preserved) or the connected partial (no poll —
// includes a meta-refresh to /hosts/{id} so the operator lands on
// the host detail).
func (s *Server) handleUIPendingAwaiting(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u := s.requireUIUser(w, r); u == nil {
return
}
rawToken := chi.URLParam(r, "token")
if rawToken == "" {
stdhttp.Error(w, "missing token", stdhttp.StatusBadRequest)
return
}
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), auth.HashToken(rawToken))
page := awaitingFragment{Token: rawToken, ExpiresAt: status.ExpiresAt}
switch {
case errors.Is(err, store.ErrNotFound):
page.State = "expired"
case err != nil:
slog.Warn("ui awaiting: lookup", "err", err)
page.State = "expired"
case status.ConsumedHost != nil:
page.State = "connected"
page.HostID = *status.ConsumedHost
if h, err := s.deps.Store.GetHost(r.Context(), *status.ConsumedHost); err == nil {
page.HostName = h.Name
page.LastSeenAt = h.LastSeenAt
}
case time.Now().After(status.ExpiresAt):
page.State = "expired"
default:
page.State = "awaiting"
}
if err := s.deps.UI.RenderPartial(w, "awaiting_agent", ui.ViewData{Page: page}); err != nil {
slog.Error("ui: render awaiting_agent", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// awaitingFragment carries the state for the polled awaiting-agent
// partial. State == awaiting | connected | expired drives both the
// copy block and whether HTMX keeps polling.
type awaitingFragment struct {
State string
Token string
ExpiresAt time.Time
HostID string
HostName string
LastSeenAt *time.Time
}
// hostChromeData is the field set the host_chrome partial reads from
// every host-detail-tab page's Page struct. Embed it as the first
// (anonymous) field of the page struct so .Page.Host / .Page.SubTab
// resolve via field promotion in the template.
type hostChromeData struct {
Host store.Host
SubTab string // snapshots | sources | schedules | repo
Crumb string // breadcrumb tail ("snapshots" / "sources" / etc)
SourceGroupCount int
ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired)
// Auto-init status surfaced from the latest 'init' job.
// InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run).
InitStatus string
InitAt *time.Time // started_at if non-nil else created_at
InitJobID string
// Latest 'restore' job — surfaced as a small line below the
// init-status one so the operator has at-a-glance visibility into
// recent destructive activity. Empty status means no restore has
// ever run on this host.
RestoreStatus string
RestoreAt *time.Time
RestoreJobID string
}
// loadHostChrome fetches the per-tab counts that every host-detail tab
// renders in the chrome (sub-tab badges + version indicator). On any
// non-fatal store error it logs and degrades to zeros — better to
// render the page with stale counts than 500 the whole tab.
func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData {
d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb}
if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil {
d.SourceGroupCount = len(groups)
} else {
slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err)
}
if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil {
d.ScheduleCount = len(scheds)
} else {
slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err)
}
if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil {
d.ScheduleVersion = v
}
if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "init"); err == nil && j != nil {
d.InitStatus = j.Status
d.InitJobID = j.ID
t := j.CreatedAt
if j.StartedAt != nil {
t = *j.StartedAt
}
d.InitAt = &t
}
if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "restore"); err == nil && j != nil {
d.RestoreStatus = j.Status
d.RestoreJobID = j.ID
t := j.CreatedAt
if j.StartedAt != nil {
t = *j.StartedAt
}
d.RestoreAt = &t
}
return d
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
hostChromeData
Snapshots []store.Snapshot
// SnapshotsShown is the number rendered (we cap at ~50 for the
// first slice; pagination lands when it matters).
SnapshotsShown int
// LegacyRestic is true when the host's restic version predates
// 0.17, in which case `restic snapshots --json` doesn't embed the
// per-snapshot summary block and the Size/Files columns render
// blank. The template uses this to attach a tooltip to those
// column headers explaining the version requirement.
LegacyRestic bool
}
// 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)
view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
Snapshots: shown,
SnapshotsShown: len(shown),
LegacyRestic: !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17),
}
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)
}
}
// generateRepoPassword returns a 24-byte URL-safe random string for
// use as a per-host rest-server password. URL-safe alphabet keeps
// it shell-safe inside single quotes — important since the operator
// pastes it into an `htpasswd -i` invocation on the rest-server.
func generateRepoPassword() (string, error) {
var buf [24]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
}
// splitPaths parses the textarea content into a clean []string —
// one path per line, leading/trailing whitespace trimmed, blanks
// dropped.
func splitPaths(s string) []string {
out := []string{}
for _, line := range strings.Split(s, "\n") {
if p := strings.TrimSpace(line); p != "" {
out = append(out, p)
}
}
return out
}
// 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)
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, "unauthorised", 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)
// Register first so future broadcasts reach us, then re-fetch the
// job to close the late-subscriber race: a fast-failing job can
// finish (DB write + Broadcast) before the browser's WS hop
// completes, leaving the JS waiting forever for a job.finished
// that already passed. If the job is already terminal here, prime
// the subscriber with a synthetic job.finished so the JS reloads.
sub := s.deps.JobHub.Register(jobID)
if cur, gerr := s.deps.Store.GetJob(r.Context(), jobID); gerr == nil && isTerminalJobStatus(cur.Status) {
if env, ferr := buildSyntheticJobFinished(cur); ferr == nil {
sub.Send(env)
}
}
sub.Run(r.Context(), c)
}
func isTerminalJobStatus(s string) bool {
switch api.JobStatus(s) {
case api.JobSucceeded, api.JobFailed, api.JobCancelled:
return true
}
return false
}
func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) {
var fin time.Time
if job.FinishedAt != nil {
fin = *job.FinishedAt
}
exit := 0
if job.ExitCode != nil {
exit = *job.ExitCode
}
errMsg := ""
if job.Error != nil {
errMsg = *job.Error
}
return api.Marshal(api.MsgJobFinished, "", api.JobFinishedPayload{
JobID: job.ID,
Status: api.JobStatus(job.Status),
ExitCode: exit,
FinishedAt: fin,
Stats: job.Stats,
Error: errMsg,
})
}
// 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)
}