d62b173712
Three independent forms on /hosts/{id}/repo so saving one section
doesn't disturb the others:
* Connection: edits repo URL, username, password (pre-filled from
the redacted GET /api/hosts/{id}/repo-credentials view; password
field shows masked stored-creds placeholder; blank password = keep
existing). On save, encrypts and pushes config.update to a
connected agent.
* Bandwidth: host-wide upload/download caps (KB/s; blank = no cap)
written via store.SetHostBandwidth. New REST endpoint
PUT /api/hosts/{id}/bandwidth for JSON callers.
* Maintenance: forget/prune/check cadences + check subset %, with
per-row enabled toggles. Reuses cronParser for validation;
auto-seeds the row if a host pre-dates the migration.
Right-rail surfaces repo size, snapshot count, snapshots-by-tag
breakdown (counted from existing snapshot tag rows), and an
'untagged snapshots are left alone' note.
Danger-zone re-init button is rendered but disabled with a hint
pointing at P2R-09 (real implementation lands there).
Validation re-renders the page with the relevant form's banner and
all other section state intact. Successful saves redirect with a
?saved=<section> query param so the page surfaces a small ✓ saved
indicator on the relevant form.
ci.yml: bump golangci-lint-action v6→v7 (separate change picked up
in this commit).
353 lines
11 KiB
Go
353 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
stdhttp "net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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
|
|
|
|
type hostRepoPage struct {
|
|
hostChromeData
|
|
|
|
// Connection (redacted view)
|
|
RepoURL string
|
|
RepoUsername string
|
|
HasPassword bool
|
|
|
|
// Bandwidth (form values, blank means "no cap")
|
|
BandwidthUp string
|
|
BandwidthDown string
|
|
|
|
// Maintenance row
|
|
Maintenance store.HostRepoMaintenance
|
|
|
|
// 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
|
|
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)
|
|
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
|
|
}
|
|
|
|
// 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, "dashboard")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// renderRepoFormError loads the page state, overlays the section's
|
|
// error / saved marker, and renders. Returns an HTTP status (422 for
|
|
// validation, 200 for success).
|
|
func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, savedSection, credErr, bwErr, mntErr string, status int) {
|
|
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.SavedSection = savedSection
|
|
page.CredentialsError = credErr
|
|
page.BandwidthError = bwErr
|
|
page.MaintenanceError = mntErr
|
|
view := s.baseView(u, "dashboard")
|
|
view.Title = host.Name + " repo · restic-manager"
|
|
view.Page = *page
|
|
if status != stdhttp.StatusOK {
|
|
w.WriteHeader(status)
|
|
}
|
|
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.", "", "", stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
|
|
// Merge with existing blob — same semantics as the JSON PUT.
|
|
existing := repoCredsBlob{}
|
|
if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID); 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.",
|
|
"", "", stdhttp.StatusUnprocessableEntity)
|
|
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, 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).",
|
|
"", stdhttp.StatusUnprocessableEntity)
|
|
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.", stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
if _, err := cronParser.Parse(expr); err != nil {
|
|
s.renderRepoPage(w, r, u, host, "", "", "",
|
|
label+" cadence didn't parse: "+err.Error(), stdhttp.StatusUnprocessableEntity)
|
|
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.", stdhttp.StatusUnprocessableEntity)
|
|
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
|
|
}
|