From c9b49637d1220b385bb9d0c4b83e2baa74bf8483 Mon Sep 17 00:00:00 2001
From: Steve Cliff
Date: Mon, 4 May 2026 10:49:57 +0100
Subject: [PATCH] =?UTF-8?q?ui:=20P2R-09=20auto-init=20UX=20=E2=80=94=20ini?=
=?UTF-8?q?t=20line=20in=20chrome=20+=20danger-zone=20re-init?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Latest 'init' job status surfaced under the host-detail vitals strip
(succeeded/failed/running/queued, with link to the live job log on
non-success). New POST /hosts/{id}/repo/reinit handler dispatches a
fresh init job after the operator types the host name to confirm;
audit row records 'host.repo_reinit'.
---
internal/server/http/server.go | 1 +
internal/server/http/ui_handlers.go | 15 ++
internal/server/http/ui_repo_reinit.go | 120 +++++++++++
internal/server/http/ui_repo_reinit_test.go | 212 ++++++++++++++++++++
web/templates/pages/host_repo.html | 12 +-
web/templates/partials/host_chrome.html | 16 ++
6 files changed, 374 insertions(+), 2 deletions(-)
create mode 100644 internal/server/http/ui_repo_reinit.go
create mode 100644 internal/server/http/ui_repo_reinit_test.go
diff --git a/internal/server/http/server.go b/internal/server/http/server.go
index 8ef3d83..02adfad 100644
--- a/internal/server/http/server.go
+++ b/internal/server/http/server.go
@@ -228,6 +228,7 @@ 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)
+ r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
// 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)
diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go
index cd4166d..ad23e03 100644
--- a/internal/server/http/ui_handlers.go
+++ b/internal/server/http/ui_handlers.go
@@ -499,6 +499,12 @@ type hostChromeData struct {
SourceGroupCount int
ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired)
+
+ // Auto-init status surfaced from the latest 'init' job.
+ // InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run).
+ InitStatus string
+ InitAt *time.Time // started_at if non-nil else created_at
+ InitJobID string
}
// loadHostChrome fetches the per-tab counts that every host-detail tab
@@ -520,6 +526,15 @@ func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, cru
if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil {
d.ScheduleVersion = v
}
+ if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "init"); err == nil && j != nil {
+ d.InitStatus = j.Status
+ d.InitJobID = j.ID
+ t := j.CreatedAt
+ if j.StartedAt != nil {
+ t = *j.StartedAt
+ }
+ d.InitAt = &t
+ }
return d
}
diff --git a/internal/server/http/ui_repo_reinit.go b/internal/server/http/ui_repo_reinit.go
new file mode 100644
index 0000000..c817df0
--- /dev/null
+++ b/internal/server/http/ui_repo_reinit.go
@@ -0,0 +1,120 @@
+// ui_repo_reinit.go — danger-zone re-init handler. Dispatches a fresh
+// `restic init` job after the operator types the host name to confirm.
+// Restic itself refuses to overwrite an existing repo (its init is
+// effectively idempotent — see the runner's "config file already
+// exists" sniff in restic.RunInit), so this is *not* a destructive
+// data wipe; it's a "try again from scratch" affordance for the
+// operator. If the rest-server bucket needs clearing the operator
+// has to do that out-of-band; the job log will say so.
+//
+// Audit-logged with action='host.repo_reinit' so the trail records
+// who triggered the wipe attempt and when.
+package http
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ stdhttp "net/http"
+ "strings"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/api"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+func (s *Server) handleUIRepoReinit(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
+ }
+ confirm := strings.TrimSpace(r.PostForm.Get("confirm_hostname"))
+ if confirm != host.Name {
+ // We don't have a dedicated re-init banner field; surface via
+ // the existing CredentialsError slot — it sits adjacent to the
+ // danger zone visually so the operator's eye lands on it.
+ s.renderRepoPage(w, r, u, host,
+ "Re-init aborted — typed hostname did not match.", "", "", "")
+ return
+ }
+ if !s.deps.Hub.Connected(host.ID) {
+ s.renderRepoPage(w, r, u, host,
+ "Host is offline — bring the agent back up before re-initializing.",
+ "", "", "")
+ return
+ }
+ // Ensure the host has creds bound; otherwise restic init can't
+ // connect to the repo.
+ if _, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ s.renderRepoPage(w, r, u, host,
+ "Bind repo credentials before re-initializing.",
+ "", "", "")
+ return
+ }
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+
+ jobID := ulid.Make().String()
+ now := time.Now().UTC()
+ if err := s.deps.Store.CreateJob(r.Context(), store.Job{
+ ID: jobID,
+ HostID: host.ID,
+ Kind: string(api.JobInit),
+ ActorKind: "user",
+ ActorID: &u.ID,
+ CreatedAt: now,
+ }); err != nil {
+ slog.Error("repo reinit: persist job", "host_id", host.ID, "err", err)
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+
+ env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
+ JobID: jobID,
+ Kind: api.JobInit,
+ })
+ if err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ sendCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+ if err := s.deps.Hub.Send(sendCtx, host.ID, env); err != nil {
+ slog.Warn("repo reinit: ws send failed", "host_id", host.ID, "err", err)
+ s.renderRepoPage(w, r, u, host,
+ "Failed to deliver the init job to the agent — try again.",
+ "", "", "")
+ return
+ }
+
+ uid := u.ID
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(),
+ UserID: &uid,
+ Actor: "user",
+ Action: "host.repo_reinit",
+ TargetKind: ptr("host"),
+ TargetID: &host.ID,
+ TS: now,
+ })
+
+ // HTMX redirect → live job log. JSON callers get a 202.
+ if wantsHTML(r) {
+ w.Header().Set("HX-Redirect", "/jobs/"+jobID)
+ w.WriteHeader(stdhttp.StatusNoContent)
+ return
+ }
+ stdhttp.Redirect(w, r, "/jobs/"+jobID, stdhttp.StatusSeeOther)
+}
diff --git a/internal/server/http/ui_repo_reinit_test.go b/internal/server/http/ui_repo_reinit_test.go
new file mode 100644
index 0000000..5b893ec
--- /dev/null
+++ b/internal/server/http/ui_repo_reinit_test.go
@@ -0,0 +1,212 @@
+// ui_repo_reinit_test.go — covers the danger-zone re-init handler:
+// hostname-confirm gate + offline guard + missing-creds guard.
+package http
+
+import (
+ "context"
+ stdhttp "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/api"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
+ "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"
+)
+
+// rawTestServerWithUI is the rawTestServer twin that also wires the
+// UI renderer in, returning the raw httptest server so callers can
+// dial /ws/agent. The UI is needed for the repo-reinit handler's
+// error re-render path.
+func rawTestServerWithUI(t *testing.T) (*Server, *httptest.Server, *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,
+ }
+ srv := New(deps)
+ ts := httptest.NewServer(srv.srv.Handler)
+ t.Cleanup(ts.Close)
+ return srv, ts, st
+}
+
+// enrolHostForUI is the enrolHostForWS twin for tests that use the
+// UI-enabled rawTestServerWithUI.
+func enrolHostForUI(t *testing.T, _ *Server, st *store.Store, name string) (hostID, token string) {
+ t.Helper()
+ hostID = ulid.Make().String()
+ token, _ = auth.NewToken()
+ if err := st.CreateHost(context.Background(), store.Host{
+ ID: hostID, Name: name, OS: "linux", Arch: "amd64",
+ EnrolledAt: time.Now().UTC(),
+ }, auth.HashToken(token), ""); err != nil {
+ t.Fatalf("create host: %v", err)
+ }
+ return hostID, token
+}
+
+// TestRepoReinitWrongHostnameRejected: typing a different name keeps
+// the page on the repo screen with an error banner; no init job is
+// dispatched.
+func TestRepoReinitWrongHostnameRejected(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, token := enrolHostForUI(t, srv, st, "reinit-host")
+
+ c := agentDial(t, srv, ts, hostID, token)
+ sendHello(t, c, "reinit-host")
+ _ = drainUntil(t, c, api.MsgScheduleSet)
+
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{"confirm_hostname": {"WRONG-NAME"}}
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/repo/reinit",
+ strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnprocessableEntity {
+ t.Fatalf("status: got %d, want 422 (re-rendered page with banner)", res.StatusCode)
+ }
+ // No init job should appear in the queue beyond the one auto-init
+ // pushed on hello (which fires when no init has run yet — let's
+ // just make sure no new "user" actor init was created).
+ var n int
+ if err := st.DB().QueryRow(
+ `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'init' AND actor_kind = 'user'`,
+ hostID).Scan(&n); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 0 {
+ t.Fatalf("user-actor init jobs: got %d, want 0 (gate was bypassed)", n)
+ }
+}
+
+// TestRepoReinitDispatchesOnMatch: typing the right hostname dispatches
+// a new init job + audit row.
+func TestRepoReinitDispatchesOnMatch(t *testing.T) {
+ t.Parallel()
+ srv, ts, st := rawTestServerWithUI(t)
+ hostID, token := enrolHostForUI(t, srv, st, "reinit-ok-host")
+ // Bind repo creds — re-init guard requires them.
+ enc, err := srv.encryptRepoCreds(repoCredsBlob{
+ RepoURL: "rest:http://r/x", RepoUsername: "u", RepoPassword: "p",
+ }, []byte("host:"+hostID))
+ if err != nil {
+ t.Fatalf("encrypt: %v", err)
+ }
+ if err := st.SetHostCredentials(context.Background(), hostID, store.CredKindRepo, enc); err != nil {
+ t.Fatalf("set creds: %v", err)
+ }
+
+ // Pre-seed a successful init so auto-init doesn't fire on hello.
+ preID := ulid.Make().String()
+ if err := st.CreateJob(context.Background(), store.Job{
+ ID: preID, HostID: hostID, Kind: "init",
+ ActorKind: "system", CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ t.Fatalf("seed init: %v", err)
+ }
+ if err := st.MarkJobFinished(context.Background(), preID, "succeeded", 0, nil, "", time.Now().UTC()); err != nil {
+ t.Fatalf("mark seed init: %v", err)
+ }
+
+ c := agentDial(t, srv, ts, hostID, token)
+ sendHello(t, c, "reinit-ok-host")
+ _ = drainUntil(t, c, api.MsgScheduleSet)
+
+ cookie := loginAsAdmin(t, st)
+
+ form := url.Values{"confirm_hostname": {"reinit-ok-host"}}
+ req, _ := stdhttp.NewRequest("POST",
+ ts.URL+"/hosts/"+hostID+"/repo/reinit",
+ strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("HX-Request", "true") // get HX-Redirect path
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusNoContent {
+ t.Fatalf("status: got %d, want 204", res.StatusCode)
+ }
+ if res.Header.Get("HX-Redirect") == "" {
+ t.Fatal("expected HX-Redirect header")
+ }
+
+ // Read the dispatched command.run; assert it's an init job.
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ mt, raw, rerr := c.Read(ctx)
+ cancel()
+ if rerr != nil {
+ break
+ }
+ if mt != websocket.MessageText {
+ continue
+ }
+ // Quick parse — we only care about the type. Avoid full
+ // envelope unmarshal here because the surrounding loop is just
+ // looking for the command.run we triggered.
+ if !strings.Contains(string(raw), `"command.run"`) {
+ continue
+ }
+ // Verify a user-actor init job row was created.
+ var n int
+ if err := st.DB().QueryRow(
+ `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'init' AND actor_kind = 'user'`,
+ hostID).Scan(&n); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 1 {
+ t.Fatalf("user-actor init jobs: got %d, want 1", n)
+ }
+ // Audit row.
+ var na int
+ if err := st.DB().QueryRow(
+ `SELECT COUNT(*) FROM audit_log WHERE action = 'host.repo_reinit' AND target_id = ?`,
+ hostID).Scan(&na); err != nil {
+ t.Fatalf("audit count: %v", err)
+ }
+ if na != 1 {
+ t.Fatalf("audit rows: got %d, want 1", na)
+ }
+ return
+ }
+ t.Fatal("timed out waiting for command.run after re-init dispatch")
+}
diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html
index a0caf6e..fda3489 100644
--- a/web/templates/pages/host_repo.html
+++ b/web/templates/pages/host_repo.html
@@ -238,8 +238,16 @@
secrets.enc is reused.
-
+
diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html
index 01606de..9e3f741 100644
--- a/web/templates/partials/host_chrome.html
+++ b/web/templates/partials/host_chrome.html
@@ -105,6 +105,22 @@
+ {{/* ---------- repo init line (P2R-09) ---------- */}}
+ {{if $page.InitStatus}}
+
+ {{if eq $page.InitStatus "succeeded"}}
+ repo ready · initialised
{{relTime $page.InitAt}}
+ {{else if eq $page.InitStatus "failed"}}
+
init failed ·
+
job {{$page.InitJobID}} · retry from the Repo tab's danger zone
+ {{else if eq $page.InitStatus "running"}}
+
init running… ·
live log →
+ {{else if eq $page.InitStatus "queued"}}
+
init queued ·
job {{$page.InitJobID}}
+ {{end}}
+
+ {{end}}
+
{{/* ---------- secondary tabs ---------- */}}