8fb1c100fd
Two independent path lists for "what does this host back up?" was
a real divergence footgun — operator types one set at Add-host time
and a different set into a schedule, both end up in the same repo,
the snapshot history looks fine until restore. Resolution: drop
host.default_paths entirely; add a `manual` flag on schedules.
A manual schedule has paths/excludes/tags/retention like any other
but no cron — it fires only via per-schedule Run-now. Single source
of truth for what gets backed up.
Schema (migration 0007):
* schedules.manual INTEGER NOT NULL DEFAULT 0.
* For every host with non-empty default_paths, seed a manual
schedule with those paths and bump host_schedule_version.
* ALTER TABLE hosts DROP COLUMN default_paths.
* ALTER TABLE enrollment_tokens RENAME COLUMN default_paths
TO initial_paths.
Original draft of this migration rebuilt hosts via the
create-new + drop-old + rename-new pattern. With foreign_keys=ON
(set in the connection DSN), DROP TABLE on the parent fired
ON DELETE CASCADE on every child of hosts(id) — schedules /
jobs / snapshots / host_credentials all wiped on the smoke env
when I tried it. SQLite 3.35+ supports column-level ALTERs
directly, so we skip the rebuild dance and avoid the cascade
trap. Six lines of SQL instead of sixty, no FK risk.
Run-now rewiring:
* New `dispatchScheduleNow(hostID, scheduleID, conn?)` helper
unifies the agent-driven path (cron fire → schedule.fire →
OnScheduleFire callback) and the UI-driven path (operator
clicks Run-now on a schedule row). Conn arg is optional; nil
falls back to Hub.Send.
* New POST /hosts/{id}/schedules/{sid}/run endpoint — per-row
Run-now button on the schedules list.
* Dashboard's per-host Run-now (handleUIRunBackup) now picks the
host's only enabled manual schedule, falls back to the only
enabled schedule, else returns "pick one in Schedules tab".
Keeps one-click for the common case.
Agent:
* Scheduler skips manual schedules in cron build (silent — they're
a normal data shape, not an error).
* Wire Schedule struct gains Manual flag.
* Schedule.fire flow unchanged — the agent only ever fires
non-manual schedules anyway.
UI:
* Add-host form retitled "Initial schedule · manual" so the
operator knows the paths become an editable schedule under
the Schedules tab. Result page calls out the manual schedule
+ points at Host > Schedules.
* Schedule edit form: "Manual schedule" checkbox at the top of
the When section; toggling it hides/shows the cron field via
inline JS. Server-side validator skips the cron requirement
when manual=true.
* Schedule list shows a "manual" tag under the status pill and
renders the When column as "— run-now only —" for manual rows.
Each row gets a Run-now button when the schedule is enabled
and the host is online.
Tests + go test ./... green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
727 lines
22 KiB
Go
727 lines
22 KiB
Go
package http
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"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
|
||
}
|
||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
stdhttp.NotFound(w, r)
|
||
return
|
||
}
|
||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host.RepoInitialisedAt == nil {
|
||
stdhttp.Error(w,
|
||
"this host's repo hasn't been initialised yet — click Initialise repo first",
|
||
stdhttp.StatusBadRequest)
|
||
return
|
||
}
|
||
pick, err := s.pickRunNowSchedule(r.Context(), hostID)
|
||
if err != nil {
|
||
stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest)
|
||
return
|
||
}
|
||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, pick.Paths)
|
||
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)
|
||
}
|
||
|
||
// pickRunNowSchedule chooses which schedule a generic per-host
|
||
// "Run now" button should dispatch when the operator hasn't picked
|
||
// one explicitly. Picks in priority order: the host's only enabled
|
||
// manual schedule, then its only enabled schedule of any kind.
|
||
// Returns a friendly error if there's nothing to run, or if the
|
||
// operator needs to disambiguate.
|
||
func (s *Server) pickRunNowSchedule(ctx context.Context, hostID string) (*store.Schedule, error) {
|
||
rows, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||
if err != nil {
|
||
return nil, errFmt("internal: %s", err)
|
||
}
|
||
enabled := make([]store.Schedule, 0, len(rows))
|
||
for _, r := range rows {
|
||
if r.Enabled {
|
||
enabled = append(enabled, r)
|
||
}
|
||
}
|
||
if len(enabled) == 0 {
|
||
return nil, errFmt("this host has no enabled schedules — add one in the Schedules tab")
|
||
}
|
||
manuals := []store.Schedule{}
|
||
for _, r := range enabled {
|
||
if r.Manual {
|
||
manuals = append(manuals, r)
|
||
}
|
||
}
|
||
switch {
|
||
case len(manuals) == 1:
|
||
s := manuals[0]
|
||
return &s, nil
|
||
case len(enabled) == 1:
|
||
s := enabled[0]
|
||
return &s, nil
|
||
default:
|
||
return nil, errFmt("this host has %d schedules — pick one from the Schedules tab", len(enabled))
|
||
}
|
||
}
|
||
|
||
func errFmt(format string, args ...any) error {
|
||
return errFmtf(format, args...)
|
||
}
|
||
|
||
// handleUIInitRepo dispatches a one-shot `restic init` job for a
|
||
// host. Surfaced in the run-now panel as a red "Initialise repo"
|
||
// button when host.repo_initialised_at IS NULL. On success it
|
||
// redirects to the live log page just like Run-now.
|
||
func (s *Server) handleUIInitRepo(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
|
||
}
|
||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
stdhttp.NotFound(w, r)
|
||
return
|
||
}
|
||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||
return
|
||
}
|
||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobInit, nil)
|
||
if code != "" {
|
||
stdhttp.Error(w, msg, status)
|
||
return
|
||
}
|
||
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
|
||
// Paths is the textarea-as-typed default-paths input. One path
|
||
// per line, blanks ignored.
|
||
Paths 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
|
||
|
||
// RepoPassword is the password the agent will use against the
|
||
// rest-server. When the operator left the password field blank
|
||
// we generate one server-side; PasswordGenerated tracks which
|
||
// path produced it so the result page can label it appropriately.
|
||
// Either way it's surfaced on the result page exactly once,
|
||
// inside the htpasswd snippet — same one-time-view rule as the
|
||
// enrolment token. Reload = gone.
|
||
RepoPassword string
|
||
PasswordGenerated bool
|
||
}
|
||
|
||
// 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")),
|
||
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 the operator didn't type a password, mint one. We surface it
|
||
// once on the result page (inside the htpasswd snippet) so they
|
||
// can paste it into the rest-server's htpasswd file.
|
||
if page.Error == "" && repoPassword == "" {
|
||
gen, err := generateRepoPassword()
|
||
if err != nil {
|
||
slog.Error("ui add_host: generate repo password", "err", err)
|
||
page.Error = "Couldn’t generate a password — see the server log for details."
|
||
} else {
|
||
repoPassword = gen
|
||
page.PasswordGenerated = true
|
||
}
|
||
}
|
||
|
||
defaultPaths := splitPaths(page.Paths)
|
||
|
||
if page.Error == "" {
|
||
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, defaultPaths)
|
||
switch err {
|
||
case nil:
|
||
page.Token = token
|
||
page.ExpiresAt = expires
|
||
page.RepoPassword = repoPassword
|
||
case errMissingRepoCreds:
|
||
page.Error = "Repo URL and password are both required."
|
||
default:
|
||
slog.Error("ui add_host: mint token", "err", err)
|
||
page.Error = "Couldn’t 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)
|
||
}
|
||
}
|
||
|
||
// 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, "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)
|
||
|
||
// 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,
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
}
|