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.
This commit is contained in:
+248
-15
@@ -7,6 +7,9 @@ import (
|
||||
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"
|
||||
@@ -17,10 +20,31 @@ import (
|
||||
// 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
|
||||
// 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
|
||||
@@ -30,6 +54,11 @@ type hostRepoPage struct {
|
||||
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
|
||||
@@ -37,6 +66,14 @@ type hostRepoPage struct {
|
||||
// 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
|
||||
@@ -44,6 +81,7 @@ type hostRepoPage struct {
|
||||
|
||||
// Inline form-error banners. Empty when no error for that section.
|
||||
CredentialsError string
|
||||
AdminCredsError string
|
||||
BandwidthError string
|
||||
MaintenanceError string
|
||||
|
||||
@@ -79,6 +117,60 @@ func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRep
|
||||
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)
|
||||
@@ -152,11 +244,11 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// renderRepoFormError loads the page state, overlays the section's
|
||||
// error banner, 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, bwErr, mntErr string) {
|
||||
// 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)
|
||||
@@ -164,6 +256,7 @@ func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u
|
||||
return
|
||||
}
|
||||
page.CredentialsError = credErr
|
||||
page.AdminCredsError = adminErr
|
||||
page.BandwidthError = bwErr
|
||||
page.MaintenanceError = mntErr
|
||||
view := s.baseView(u)
|
||||
@@ -198,7 +291,7 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt
|
||||
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.", "", "")
|
||||
s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,7 +310,7 @@ func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhtt
|
||||
if existing.RepoPassword == "" {
|
||||
s.renderRepoPage(w, r, u, host,
|
||||
"No password on file yet — set one before saving the URL/username.",
|
||||
"", "")
|
||||
"", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -256,7 +349,7 @@ func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
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, "",
|
||||
s.renderRepoPage(w, r, u, host, "", "",
|
||||
"Bandwidth caps must be non-negative whole numbers (or blank for no cap).",
|
||||
"")
|
||||
return
|
||||
@@ -294,19 +387,19 @@ func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhtt
|
||||
"forget": forgetCron, "prune": pruneCron, "check": checkCron,
|
||||
} {
|
||||
if expr == "" {
|
||||
s.renderRepoPage(w, r, u, host, "", "",
|
||||
s.renderRepoPage(w, r, u, host, "", "", "",
|
||||
label+" cadence is required.")
|
||||
return
|
||||
}
|
||||
if _, err := cronParser.Parse(expr); err != nil {
|
||||
s.renderRepoPage(w, r, u, host, "", "",
|
||||
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, "", "",
|
||||
s.renderRepoPage(w, r, u, host, "", "", "",
|
||||
"check subset % must be between 0 and 100.")
|
||||
return
|
||||
}
|
||||
@@ -348,3 +441,143 @@ func parseOptionalNonNegInt(s string) (*int, error) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user