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:
2026-05-03 23:18:16 +01:00
parent b07cb14320
commit ccccc6aa33
4 changed files with 761 additions and 15 deletions
+3
View File
@@ -220,6 +220,9 @@ func (s *Server) routes(r chi.Router) {
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
// Admin credentials form (separate slot for prune-capable user).
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
// Schedules tab + create/edit/delete forms.
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
+248 -15
View File
@@ -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)
}
+396
View File
@@ -0,0 +1,396 @@
// ui_repo_test.go — integration tests for the Repo page HTML UI.
// Covers: admin-creds form rendering, stats panel, lock banner,
// run-now button disabled states, admin-creds form save/delete.
package http
import (
"context"
"io"
stdhttp "net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"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"
)
// newTestServerWithUI creates a server that includes the UI renderer so
// HTML page tests can render and inspect the full template output.
func newTestServerWithUI(t *testing.T) (*Server, string, *store.Store) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(dir, "secret.key")
_ = crypto.GenerateKeyFile(keyPath)
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
renderer, err := ui.New()
if err != nil {
t.Fatalf("ui.New: %v", err)
}
deps := Deps{
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
Store: st,
AEAD: aead,
Hub: ws.NewHub(),
UI: renderer,
}
s := New(deps)
ts := httptest.NewServer(s.srv.Handler)
t.Cleanup(ts.Close)
return s, ts.URL, st
}
// getRepoPage fetches /hosts/{id}/repo and returns the body string.
func getRepoPage(t *testing.T, baseURL, hostID string, cookie *stdhttp.Cookie) string {
t.Helper()
client := &stdhttp.Client{
CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
},
}
req, err := stdhttp.NewRequest("GET", baseURL+"/hosts/"+hostID+"/repo", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.AddCookie(cookie)
res, err := client.Do(req)
if err != nil {
t.Fatalf("GET /hosts/%s/repo: %v", hostID, err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Fatalf("GET /hosts/%s/repo: want 200, got %d", hostID, res.StatusCode)
}
raw, _ := io.ReadAll(res.Body)
return string(raw)
}
// postForm posts URL-encoded form data to path, following no redirects,
// and returns the status code and Location header.
func postForm(t *testing.T, baseURL, path string, data url.Values, cookie *stdhttp.Cookie) (int, string) {
t.Helper()
client := &stdhttp.Client{
CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
},
}
req, err := stdhttp.NewRequest("POST", baseURL+path, strings.NewReader(data.Encode()))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != nil {
req.AddCookie(cookie)
}
res, err := client.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
defer res.Body.Close()
return res.StatusCode, res.Header.Get("Location")
}
// ----- rendering tests ------------------------------------------------
// TestUIRepoPageRendersAdminCredsForm — visit /hosts/{id}/repo for a
// host with no admin creds. Assert the page contains the admin-creds
// section heading and the "not yet set" placeholder text.
func TestUIRepoPageRendersAdminCredsForm(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "repo-page-admin-form")
body := getRepoPage(t, baseURL, hostID, cookie)
if !strings.Contains(body, "Admin credentials") {
t.Error("page missing 'Admin credentials' heading")
}
if !strings.Contains(body, "— not yet set —") {
t.Error("page missing '— not yet set —' placeholder for admin password")
}
}
// TestUIRepoPageRendersStatsPanel — seed a host_repo_stats row, render
// the page, assert "Repo health" panel and the seeded values appear.
func TestUIRepoPageRendersStatsPanel(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "repo-page-stats")
totalSize := int64(5_000_000_000) // 5 GB
checkStatus := "ok"
checkAt := time.Now().Add(-2 * time.Hour).UTC()
if err := st.UpsertHostRepoStats(context.Background(), hostID, store.HostRepoStats{
TotalSizeBytes: &totalSize,
LastCheckAt: &checkAt,
LastCheckStatus: checkStatus,
}); err != nil {
t.Fatalf("upsert stats: %v", err)
}
body := getRepoPage(t, baseURL, hostID, cookie)
if !strings.Contains(body, "Repo health") {
t.Error("page missing 'Repo health' heading")
}
// The bytes helper renders 5 GB as "5.0 GB" (with a <span> unit suffix)
if !strings.Contains(body, "5.0") {
t.Error("page missing '5.0' (total size formatted bytes)")
}
if !strings.Contains(body, "ok") {
t.Error("page missing 'ok' check status")
}
}
// TestUIRepoPageRendersLockBanner — seed stats with LockPresent=true,
// render, assert stale lock warning appears.
func TestUIRepoPageRendersLockBanner(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "repo-page-lock")
lockPresent := true
if err := st.UpsertHostRepoStats(context.Background(), hostID, store.HostRepoStats{
LockPresent: &lockPresent,
}); err != nil {
t.Fatalf("upsert stats: %v", err)
}
body := getRepoPage(t, baseURL, hostID, cookie)
if !strings.Contains(body, "Stale lock detected") {
t.Error("page missing stale lock warning")
}
}
// TestUIRepoRunNowButtonsDisabledWhenOffline — host not in the Hub
// (not connected), render, assert all three buttons carry disabled.
func TestUIRepoRunNowButtonsDisabledWhenOffline(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "repo-page-offline")
// No WS connection → Hub.Connected returns false.
body := getRepoPage(t, baseURL, hostID, cookie)
// All three Run-now buttons should have disabled.
// Each button appears once in the template with class "btn btn-secondary"
// and hx-post attributes. The disabled attribute is added conditionally.
// Count occurrences of 'disabled' in the Run-now section.
runNowIdx := strings.Index(body, "Run now · one-time")
dangerIdx := strings.Index(body, "Danger zone")
if runNowIdx < 0 {
t.Fatal("page missing 'Run now · one-time' section")
}
if dangerIdx < 0 {
t.Fatal("page missing 'Danger zone' section")
}
runNowSection := body[runNowIdx:dangerIdx]
disabledCount := strings.Count(runNowSection, "disabled")
if disabledCount < 3 {
t.Errorf("expected at least 3 disabled attributes in Run-now section (one per button), got %d", disabledCount)
}
}
// TestUIRepoPruneButtonDisabledWithoutAdminCreds — host is online but
// no admin creds set. Assert prune button has disabled and mentions
// "set admin credentials first".
func TestUIRepoPruneButtonDisabledWithoutAdminCreds(t *testing.T) {
t.Parallel()
srv, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "repo-page-prune-no-admin")
// Register the host as "connected" in the Hub so the online check passes.
// We use a fake conn by injecting directly — for a simpler approach,
// rely on the fact that the Hub.Connected call just needs the ID registered.
// We can't easily fake a WS conn in a unit test, so instead we verify
// that even without the hub connected the prune button still has
// "set admin credentials first" text since that check runs first.
_ = srv // suppress unused warning
body := getRepoPage(t, baseURL, hostID, cookie)
if !strings.Contains(body, "set admin credentials first") {
t.Error("page missing 'set admin credentials first' on prune button")
}
}
// ----- admin-creds form save/delete tests ----------------------------
// TestUIAdminCredentialsSaveRoundTrip — POST form-encoded body to
// /hosts/{id}/admin-credentials, follow redirect, assert page now shows
// "stored, leave blank to keep" placeholder. Audit row landed.
func TestUIAdminCredentialsSaveRoundTrip(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie, userID := loginAsAdminWithID(t, st)
hostID := makeHost(t, st, "admin-save-roundtrip")
// POST admin credentials.
status, loc := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials", url.Values{
"repo_url": {"rest:http://admin.example/h"},
"repo_username": {"admin-user"},
"repo_password": {"s3cr3t-admin"},
}, cookie)
if status != stdhttp.StatusSeeOther {
t.Fatalf("save: want 303, got %d", status)
}
if !strings.Contains(loc, "saved=admin_credentials") {
t.Errorf("redirect location should contain saved=admin_credentials, got %q", loc)
}
// Follow redirect.
body := getRepoPage(t, baseURL, hostID, cookie)
if !strings.Contains(body, "stored, leave blank to keep") {
t.Error("after save: page missing 'stored, leave blank to keep' placeholder for admin password")
}
// Audit row should exist.
ctx := context.Background()
rows, err := st.DB().QueryContext(ctx,
`SELECT action, user_id FROM audit_log WHERE target_id = ? AND action = 'host.admin_credentials_set'`,
hostID)
if err != nil {
t.Fatalf("query audit: %v", err)
}
defer rows.Close()
found := false
for rows.Next() {
var action string
var gotUID *string
if err := rows.Scan(&action, &gotUID); err != nil {
t.Fatalf("scan: %v", err)
}
found = true
if gotUID == nil || *gotUID != userID {
t.Errorf("audit row user_id: want %q, got %v", userID, gotUID)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("rows.Err: %v", err)
}
if !found {
t.Error("audit row with action='host.admin_credentials_set' not found")
}
}
// TestUIAdminCredentialsDelete — POST to the delete route, assert
// admin row gone and audit row landed.
func TestUIAdminCredentialsDelete(t *testing.T) {
t.Parallel()
srv, baseURL, st := newTestServerWithUI(t)
cookie, userID := loginAsAdminWithID(t, st)
hostID := makeHost(t, st, "admin-delete")
ctx := context.Background()
// Seed admin creds directly.
enc, err := srv.encryptRepoCreds(repoCredsBlob{
RepoURL: "rest:http://admin.example/h",
RepoPassword: "pw",
}, []byte("host:"+hostID+":admin"))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if err := st.SetHostCredentials(ctx, hostID, store.CredKindAdmin, enc); err != nil {
t.Fatalf("set admin creds: %v", err)
}
// POST to delete route.
status, loc := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials/delete", url.Values{}, cookie)
if status != stdhttp.StatusSeeOther {
t.Fatalf("delete: want 303, got %d", status)
}
if !strings.Contains(loc, "saved=admin_credentials") {
t.Errorf("redirect location: want saved=admin_credentials, got %q", loc)
}
// Admin row should be gone.
if _, err := st.GetHostCredentials(ctx, hostID, store.CredKindAdmin); err == nil {
t.Error("admin creds row still present after delete")
}
// Audit row.
rows, err := st.DB().QueryContext(ctx,
`SELECT action, user_id FROM audit_log WHERE target_id = ? AND action = 'host.admin_credentials_deleted'`,
hostID)
if err != nil {
t.Fatalf("query audit: %v", err)
}
defer rows.Close()
found := false
for rows.Next() {
var action string
var gotUID *string
if err := rows.Scan(&action, &gotUID); err != nil {
t.Fatalf("scan: %v", err)
}
found = true
if gotUID == nil || *gotUID != userID {
t.Errorf("audit row user_id: want %q, got %v", userID, gotUID)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("rows.Err: %v", err)
}
if !found {
t.Error("audit row with action='host.admin_credentials_deleted' not found")
}
}
// TestUIAdminCredentialsDeleteIdempotent — POST to the delete route
// when no admin creds exist → 303 redirect (no 404 / 500).
func TestUIAdminCredentialsDeleteIdempotent(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "admin-delete-noop")
status, _ := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials/delete", url.Values{}, cookie)
if status != stdhttp.StatusSeeOther {
t.Fatalf("delete (noop): want 303, got %d", status)
}
}
// TestUIAdminCredentialsSaveAllBlankIsNoop — POST empty form → 303
// redirect, no row created.
func TestUIAdminCredentialsSaveAllBlankIsNoop(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "admin-save-blank")
status, _ := postForm(t, baseURL, "/hosts/"+hostID+"/admin-credentials", url.Values{
"repo_url": {""},
"repo_username": {""},
"repo_password": {""},
}, cookie)
if status != stdhttp.StatusSeeOther {
t.Fatalf("blank save: want 303, got %d", status)
}
// No admin row should have been created.
if _, err := st.GetHostCredentials(context.Background(), hostID, store.CredKindAdmin); err == nil {
t.Error("admin creds row created unexpectedly for blank save")
}
}
+114
View File
@@ -42,6 +42,54 @@
</div>
</form>
{{/* ---------- Admin credentials (optional) ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">
Admin credentials <span class="text-ink-fade normal-case">· prune-only · optional</span>
</h2>
<form method="post" action="/hosts/{{$host.ID}}/admin-credentials" class="panel rounded-[7px] p-5">
{{if $page.AdminCredsError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{$page.AdminCredsError}}
</div>
{{end}}
{{if eq $page.SavedSection "admin_credentials"}}
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}}
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
Only needed for rest-server repos that distinguish an append-only
user (everyday backups) from a delete-capable user (prune /
forget). For S3 / B2 / SFTP / local, leave this blank — the
everyday repo credentials handle prune too.
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="field-label" for="admin_repo_url">Repo URL <span class="text-ink-fade">· usually same as above</span></label>
<input id="admin_repo_url" name="repo_url" type="text" class="field mono" value="{{$page.AdminURL}}" />
</div>
<div>
<label class="field-label" for="admin_repo_username">Username</label>
<input id="admin_repo_username" name="repo_username" type="text" class="field mono" value="{{$page.AdminUsername}}" />
</div>
<div class="col-span-2">
<label class="field-label" for="admin_repo_password">Password</label>
<input id="admin_repo_password" name="repo_password" type="password" class="field mono"
placeholder="{{if $page.HasAdminPassword}}•••••••••••••••• · stored, leave blank to keep{{else}}— not yet set —{{end}}"
autocomplete="new-password" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-line-soft flex gap-2 items-center">
<button type="submit" class="btn btn-primary">Save admin credentials</button>
{{if $page.HasAdminPassword}}
<button type="submit" form="admin-creds-clear" class="btn btn-secondary"
onclick="return confirm('Clear admin credentials? Prune jobs will be refused until you re-set them.');">Clear</button>
{{end}}
</div>
</form>
{{if $page.HasAdminPassword}}
<form id="admin-creds-clear" method="post" action="/hosts/{{$host.ID}}/admin-credentials/delete"></form>
{{end}}
{{/* ---------- Bandwidth ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Bandwidth · host-wide</h2>
<form method="post" action="/hosts/{{$host.ID}}/repo/bandwidth" class="panel rounded-[7px] p-5">
@@ -138,6 +186,37 @@
</div>
</form>
{{/* ---------- Run now · one-time ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Run now · one-time</h2>
<div class="panel rounded-[7px] p-5">
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
Operator-triggered. Output streams live to the job log. Cadence-driven runs land independently from the server-side ticker.
</p>
<div class="grid grid-cols-3 gap-3">
<button type="button"
hx-post="/hosts/{{$host.ID}}/repo/check"
hx-confirm="Run check now ({{$m.CheckSubsetPct}}% data subset)?"
class="btn btn-secondary"
{{if not $page.Online}}disabled title="agent is offline"{{end}}>
check
</button>
<button type="button"
hx-post="/hosts/{{$host.ID}}/repo/prune"
hx-confirm="Run prune now? Removes data not referenced by any snapshot — heavy operation."
class="btn btn-secondary"
{{if not $page.HasAdminPassword}}disabled title="set admin credentials first"{{else if not $page.Online}}disabled title="agent is offline"{{end}}>
prune
</button>
<button type="button"
hx-post="/hosts/{{$host.ID}}/repo/unlock"
hx-confirm="Clear stale repo locks?"
class="btn btn-secondary"
{{if not $page.Online}}disabled title="agent is offline"{{end}}>
unlock
</button>
</div>
</div>
{{/* ---------- Danger zone ---------- */}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-bad mt-9 mb-3.5">Danger zone</h2>
<div class="panel rounded-[7px] p-5"
@@ -179,6 +258,41 @@
</div>
</div>
{{/* ---------- Repo health ---------- */}}
{{if $page.StatsView}}
{{$s := $page.StatsView}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Repo health</h2>
<div class="panel rounded-[7px] p-5 text-[13px]">
{{if $s.LockPresent}}
<div class="rounded-[6px] px-3.5 py-3 text-[12.5px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">
Stale lock detected on the most recent check. Run <span class="mono">unlock</span> above to clear it before the next backup.
</div>
{{end}}
<dl class="grid grid-cols-2 gap-y-2 gap-x-4">
{{if $s.HasTotalSize}}
<dt class="text-ink-fade">Total size</dt>
<dd class="mono text-right">{{bytes $s.TotalSizeBytes}}</dd>
{{end}}
{{if $s.HasRawSize}}
<dt class="text-ink-fade">Raw size <span class="text-ink-fade text-[11px]">· pre-dedup</span></dt>
<dd class="mono text-right">{{bytes $s.RawSizeBytes}}</dd>
{{end}}
{{if $s.HasLastCheck}}
<dt class="text-ink-fade">Last check</dt>
<dd class="mono text-right text-[12px]">
{{$s.LastCheckAgo}}
{{if $s.LastCheckStatus}} · <span class="{{if eq $s.LastCheckStatus "ok"}}text-ok{{else if eq $s.LastCheckStatus "errors_found"}}text-bad{{else}}text-ink-mid{{end}}">{{$s.LastCheckStatus}}</span>{{end}}
</dd>
{{end}}
{{if $s.HasLastPrune}}
<dt class="text-ink-fade">Last prune</dt>
<dd class="mono text-right text-[12px]">{{$s.LastPruneAgo}}</dd>
{{end}}
</dl>
</div>
{{end}}
{{if gt (len $page.GroupNames) 0}}
<h2 class="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-ink-mute mt-7 mb-3.5">Snapshots by source</h2>
<div class="panel rounded-[7px] p-4">