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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user