Files
restic-manager/internal/server/http/ui_repo.go
T
steve c5b52df7ed ui: Slice E — admin creds form + run-now buttons + repo health panel
- hostRepoPage gains AdminURL/AdminUsername/HasAdminPassword, Online,
  and StatsView (pre-dereferenced projection of host_repo_stats).
- loadHostRepoPage loads the admin slot (tolerating ErrNotFound),
  hub.Connected, and stats (tolerating ErrNotFound).
- renderRepoPage gains an adminErr parameter; all callers updated.
- handleUIAdminCredentialsSave / handleUIAdminCredentialsDelete added
  (form-POST handlers mirroring the repo-creds pattern, with audit).
- Routes /hosts/{id}/admin-credentials POST and /delete POST registered.
- Template: Admin credentials form after Connection, Run-now HTMX
  buttons after Maintenance, Repo health stats panel in right rail.
- Tests: 9 new tests covering rendering, disabled states, save/delete
  round-trips, audit rows, and idempotent delete.
2026-05-04 10:19:15 +01:00

584 lines
18 KiB
Go

package http
import (
"encoding/json"
"errors"
"log/slog"
stdhttp "net/http"
"strconv"
"strings"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// ui_repo.go — HTML form-driven repo-tab handlers (connection,
// bandwidth caps, maintenance cadences, danger-zone re-init). Splits
// the page into three independent forms so saving one section
// doesn't disturb the others.
//
// GET /hosts/{id}/repo — render
// POST /hosts/{id}/repo/credentials — connection
// POST /hosts/{id}/repo/bandwidth — host-wide bw caps
// POST /hosts/{id}/repo/maintenance — forget/prune/check cadences
// POST /hosts/{id}/admin-credentials — admin (prune) creds
// POST /hosts/{id}/admin-credentials/delete — clear admin creds
// repoStatsView is a flat, pre-dereferenced projection of
// store.HostRepoStats for use in templates. Nil pointer fields are
// collapsed to zero/false and accompanied by a Has* sentinel so the
// template can distinguish "zero" from "not yet known."
type repoStatsView struct {
HasTotalSize bool
TotalSizeBytes int64
HasRawSize bool
RawSizeBytes int64
HasLastCheck bool
LastCheckAt time.Time
LastCheckAgo string
LastCheckStatus string
LockPresent bool
HasLastPrune bool
LastPruneAt time.Time
LastPruneAgo string
}
type hostRepoPage struct {
hostChromeData
// Connection (redacted view)
RepoURL string
RepoUsername string
HasPassword bool
// Admin credentials (optional, prune-only — separate slot).
AdminURL string
AdminUsername string
HasAdminPassword bool
// Bandwidth (form values, blank means "no cap")
BandwidthUp string
BandwidthDown string
// Maintenance row
Maintenance store.HostRepoMaintenance
// Online mirrors Hub.Connected so Run-now button disabled state is
// accurate at render time.
Online bool
// StatsView is a pre-dereferenced projection of host_repo_stats.
// Nil when no row exists yet (fresh hosts).
StatsView *repoStatsView
// Snapshots-by-tag — map[group_name]count, plus an "untagged" row.
SnapshotsByTag map[string]int
UntaggedSnapshots int
GroupNames []string // ordered, for stable rendering
// Inline form-error banners. Empty when no error for that section.
CredentialsError string
AdminCredsError string
BandwidthError string
MaintenanceError string
// Highlight which form was just submitted, for the success-state
// border (subtle UX nicety; empty = no recent save).
SavedSection string
}
// loadHostRepoPage builds the read-only side of the page state. The
// per-form save handlers re-call this and overlay any banner / saved
// markers before rendering.
func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRepoPage, error) {
p := &hostRepoPage{
hostChromeData: s.loadHostChrome(r, host, "repo", "repo"),
}
// Credentials (redacted).
enc, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo)
switch {
case err == nil:
plain, derr := s.deps.AEAD.Decrypt(enc, []byte("host:"+host.ID))
if derr == nil {
var blob repoCredsBlob
if jerr := json.Unmarshal(plain, &blob); jerr == nil {
p.RepoURL = blob.RepoURL
p.RepoUsername = blob.RepoUsername
p.HasPassword = blob.RepoPassword != ""
}
}
case errors.Is(err, store.ErrNotFound):
// no creds yet — leave fields empty
default:
return nil, err
}
// Admin credentials (optional — prune-only slot).
adminEnc, aerr := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin)
switch {
case aerr == nil:
plain, derr := s.deps.AEAD.Decrypt(adminEnc, []byte("host:"+host.ID+":admin"))
if derr == nil {
var blob repoCredsBlob
if jerr := json.Unmarshal(plain, &blob); jerr == nil {
p.AdminURL = blob.RepoURL
p.AdminUsername = blob.RepoUsername
p.HasAdminPassword = blob.RepoPassword != ""
}
}
case errors.Is(aerr, store.ErrNotFound):
// admin slot not configured — fine
default:
return nil, aerr
}
// Online status.
if s.deps.Hub != nil {
p.Online = s.deps.Hub.Connected(host.ID)
}
// Repo stats (tolerate ErrNotFound — fresh hosts have no row yet).
if stats, serr := s.deps.Store.GetHostRepoStats(r.Context(), host.ID); serr == nil {
sv := &repoStatsView{}
if stats.TotalSizeBytes != nil {
sv.HasTotalSize = true
sv.TotalSizeBytes = *stats.TotalSizeBytes
}
if stats.RawSizeBytes != nil {
sv.HasRawSize = true
sv.RawSizeBytes = *stats.RawSizeBytes
}
if stats.LastCheckAt != nil {
sv.HasLastCheck = true
sv.LastCheckAt = *stats.LastCheckAt
sv.LastCheckAgo = relTimeAgo(*stats.LastCheckAt)
}
sv.LastCheckStatus = stats.LastCheckStatus
if stats.LockPresent != nil {
sv.LockPresent = *stats.LockPresent
}
if stats.LastPruneAt != nil {
sv.HasLastPrune = true
sv.LastPruneAt = *stats.LastPruneAt
sv.LastPruneAgo = relTimeAgo(*stats.LastPruneAt)
}
p.StatsView = sv
} else if !errors.Is(serr, store.ErrNotFound) {
return nil, serr
}
// Bandwidth.
if host.BandwidthUpKBps != nil {
p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps)
}
if host.BandwidthDownKBps != nil {
p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps)
}
// Maintenance — auto-seed defaults if missing.
m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
if err != nil && errors.Is(err, store.ErrNotFound) {
if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); seedErr != nil {
return nil, seedErr
}
m, err = s.deps.Store.GetRepoMaintenance(r.Context(), host.ID)
}
if err != nil {
return nil, err
}
p.Maintenance = *m
// Snapshot counts by tag — used for the right-rail breakdown.
groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
if err == nil {
groupNameSet := make(map[string]struct{}, len(groups))
for _, g := range groups {
p.GroupNames = append(p.GroupNames, g.Name)
groupNameSet[g.Name] = struct{}{}
}
if snaps, serr := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID); serr == nil {
p.SnapshotsByTag = make(map[string]int, len(groups))
for _, sn := range snaps {
matched := false
for _, t := range sn.Tags {
if _, ok := groupNameSet[t]; ok {
p.SnapshotsByTag[t]++
matched = true
}
}
if !matched {
p.UntaggedSnapshots++
}
}
}
}
return p, nil
}
func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
page, err := s.loadHostRepoPage(r, *host)
if err != nil {
slog.Error("ui repo: load page", "host_id", host.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page.SavedSection = r.URL.Query().Get("saved")
view := s.baseView(u)
view.Title = host.Name + " repo · restic-manager"
view.Page = *page
if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
slog.Error("ui: render host_repo", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// renderRepoPage loads the page state, overlays section error banners,
// and renders with a 422. Save-success goes through a 303 redirect
// with `?saved=<section>` instead, so this path is for validation
// failures only.
func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, adminErr, bwErr, mntErr string) {
page, err := s.loadHostRepoPage(r, *host)
if err != nil {
slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page.CredentialsError = credErr
page.AdminCredsError = adminErr
page.BandwidthError = bwErr
page.MaintenanceError = mntErr
view := s.baseView(u)
view.Title = host.Name + " repo · restic-manager"
view.Page = *page
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
slog.Error("ui: render host_repo", "err", err)
}
}
// handleUIRepoCredentialsSave updates the host's stored repo URL,
// username, and (optionally) password. Empty password means "leave
// the existing one alone" — passwords are never round-tripped to the
// browser, so a blank field is the only way an operator can save the
// other fields without re-typing the password.
func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
repoURL := strings.TrimSpace(r.PostForm.Get("repo_url"))
repoUser := strings.TrimSpace(r.PostForm.Get("repo_username"))
repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately
if repoURL == "" {
s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "", "")
return
}
// Merge with existing blob — same semantics as the JSON PUT.
existing := repoCredsBlob{}
if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo); err == nil {
if plain, derr := s.deps.AEAD.Decrypt(cur, []byte("host:"+host.ID)); derr == nil {
_ = json.Unmarshal(plain, &existing)
}
}
existing.RepoURL = repoURL
existing.RepoUsername = repoUser
if repoPass != "" {
existing.RepoPassword = repoPass
}
if existing.RepoPassword == "" {
s.renderRepoPage(w, r, u, host,
"No password on file yet — set one before saving the URL/username.",
"", "", "")
return
}
enc, err := s.encryptRepoCreds(existing, []byte("host:"+host.ID))
if err != nil {
slog.Error("ui repo creds: encrypt", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, store.CredKindRepo, enc); err != nil {
slog.Error("ui repo creds: persist", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) {
_ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing)
}
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther)
}
// handleUIRepoBandwidthSave updates the host's upload/download caps.
// Empty input → nil pointer → no cap. Negative → error.
func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up"))
down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down"))
if upErr != nil || downErr != nil {
s.renderRepoPage(w, r, u, host, "", "",
"Bandwidth caps must be non-negative whole numbers (or blank for no cap).",
"")
return
}
if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil {
slog.Error("ui repo bandwidth: persist", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=bandwidth", stdhttp.StatusSeeOther)
}
// handleUIRepoMaintenanceSave updates the forget/prune/check
// cadences in one go. Cron expressions parsed with the same parser
// the agent + REST handler use.
func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
forgetCron := strings.TrimSpace(r.PostForm.Get("forget_cron"))
pruneCron := strings.TrimSpace(r.PostForm.Get("prune_cron"))
checkCron := strings.TrimSpace(r.PostForm.Get("check_cron"))
subsetStr := strings.TrimSpace(r.PostForm.Get("check_subset_pct"))
for label, expr := range map[string]string{
"forget": forgetCron, "prune": pruneCron, "check": checkCron,
} {
if expr == "" {
s.renderRepoPage(w, r, u, host, "", "", "",
label+" cadence is required.")
return
}
if _, err := cronParser.Parse(expr); err != nil {
s.renderRepoPage(w, r, u, host, "", "", "",
label+" cadence didn't parse: "+err.Error())
return
}
}
subset, err := strconv.Atoi(subsetStr)
if err != nil || subset < 0 || subset > 100 {
s.renderRepoPage(w, r, u, host, "", "", "",
"check subset % must be between 0 and 100.")
return
}
if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); err != nil {
slog.Error("ui repo maintenance: seed", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
m := store.HostRepoMaintenance{
HostID: host.ID,
ForgetCron: forgetCron,
ForgetEnabled: r.PostForm.Get("forget_enabled") == "1",
PruneCron: pruneCron,
PruneEnabled: r.PostForm.Get("prune_enabled") == "1",
CheckCron: checkCron,
CheckEnabled: r.PostForm.Get("check_enabled") == "1",
CheckSubsetPct: subset,
}
if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil {
slog.Error("ui repo maintenance: persist", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=maintenance", stdhttp.StatusSeeOther)
}
// parseOptionalNonNegInt returns (nil, nil) for an empty string, or
// (*int, nil) for a non-negative integer. Negative or non-numeric →
// error. Used for bandwidth caps where blank means "no limit".
func parseOptionalNonNegInt(s string) (*int, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return nil, errors.New("invalid")
}
return &n, nil
}
// relTimeAgo returns a short human-readable relative-time string like
// "5m ago", "3h ago", "2d ago" for use in stats panels. Does not use
// the template funcMap so it can be called from Go directly.
func relTimeAgo(t time.Time) string {
d := time.Since(t)
if d < 0 {
d = 0
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return strconv.Itoa(int(d.Minutes())) + "m ago"
case d < 24*time.Hour:
return strconv.Itoa(int(d.Hours())) + "h ago"
case d < 30*24*time.Hour:
return strconv.Itoa(int(d.Hours()/24)) + "d ago"
default:
return t.Format("2006-01-02")
}
}
// handleUIAdminCredentialsSave handles the HTML form POST to
// /hosts/{id}/admin-credentials. Mirrors handleUIRepoCredentialsSave
// but operates on the admin slot (store.CredKindAdmin, AAD "host:<id>:admin").
// Re-renders the page with an inline error on validation failure;
// redirects with ?saved=admin_credentials on success.
func (s *Server) handleUIAdminCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
repoURL := strings.TrimSpace(r.PostForm.Get("repo_url"))
repoUser := strings.TrimSpace(r.PostForm.Get("repo_username"))
repoPass := r.PostForm.Get("repo_password")
// All blank → no-op save (operator hit Save without filling anything).
// We treat this as harmless — they may have wanted to clear via the
// Clear button instead. Only validate if they've started filling fields.
if repoURL == "" && repoUser == "" && repoPass == "" {
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther)
return
}
aad := []byte("host:" + host.ID + ":admin")
// Merge with the existing admin row, if any.
existing := repoCredsBlob{}
if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin); err == nil {
if plain, derr := s.deps.AEAD.Decrypt(cur, aad); derr == nil {
_ = json.Unmarshal(plain, &existing)
}
}
existing.RepoURL = repoURL
existing.RepoUsername = repoUser
if repoPass != "" {
existing.RepoPassword = repoPass
}
if existing.RepoURL == "" {
s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "")
return
}
if existing.RepoPassword == "" {
s.renderRepoPage(w, r, u, host, "",
"No password on file yet — set one before saving the URL/username.",
"", "")
return
}
enc, err := s.encryptRepoCreds(existing, aad)
if err != nil {
slog.Error("ui admin creds: encrypt", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, store.CredKindAdmin, enc); err != nil {
slog.Error("ui admin creds: persist", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "host.admin_credentials_set",
TargetKind: ptr("host"),
TargetID: &host.ID,
TS: nowUTC(),
})
if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) {
if perr := s.pushAdminCredsToAgent(r.Context(), host.ID); perr != nil {
slog.Warn("ui admin creds: push to agent", "host_id", host.ID, "err", perr)
}
}
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther)
}
// handleUIAdminCredentialsDelete handles the HTML form POST to
// /hosts/{id}/admin-credentials/delete. Removes the admin slot and
// redirects back to the repo page. Treats "not found" as success
// (idempotent delete from the operator's point of view).
func (s *Server) handleUIAdminCredentialsDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
host, ok := s.loadHostForUI(w, r)
if !ok {
return
}
err := s.deps.Store.DeleteHostCredentials(r.Context(), host.ID, store.CredKindAdmin)
if err != nil && !errors.Is(err, store.ErrNotFound) {
slog.Error("ui admin creds: delete", "host_id", host.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err == nil {
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "host.admin_credentials_deleted",
TargetKind: ptr("host"),
TargetID: &host.ID,
TS: nowUTC(),
})
}
stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther)
}