86f7c17d9d
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>
240 lines
7.5 KiB
Go
240 lines
7.5 KiB
Go
package http
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
stdhttp "net/http"
|
|
|
|
"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/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's "Run now" buttons call via hx-post. Returns
|
|
// 204 on success — HTMX swap=none means "did the thing, no DOM
|
|
// change needed." Failures return text in the body so HTMX's
|
|
// response-header inspection surfaces it.
|
|
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
|
|
}
|
|
_, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, nil)
|
|
if code != "" {
|
|
stdhttp.Error(w, msg, status)
|
|
return
|
|
}
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
}
|
|
|
|
// 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)
|
|
}
|