From 636022dc31eb5f6087cf31c7d3c280f93780da1f Mon Sep 17 00:00:00 2001
From: kisfenyo
Date: Thu, 29 Oct 2026 16:36:34 +0100
Subject: [PATCH] hub 0.177.0: Reboot guest on the host page (D65 = A,
reboot_guest in the agent's closed list, 30-minute expiry); R-1346: the
slow-request line and the vouch save's per-leg timing
---
hub/CHANGELOG.md | 22 ++++++++++
hub/internal/accesslog/accesslog.go | 25 ++++++++++-
hub/internal/accesslog/accesslog_test.go | 44 +++++++++++++++++++
hub/internal/store/opactions.go | 21 +++++++--
hub/internal/store/opactions_test.go | 35 +++++++++++++--
hub/internal/web/agent_only_vouch_test.go | 19 ++++++++
hub/internal/web/configs.go | 9 ++++
hub/internal/web/host_opactions_test.go | 40 ++++++++++++++++-
hub/internal/web/hosts_test.go | 13 +++---
.../web/templates/host_detail_body.html | 11 +++++
10 files changed, 223 insertions(+), 16 deletions(-)
diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md
index 5e7ff990..9f619ab2 100644
--- a/hub/CHANGELOG.md
+++ b/hub/CHANGELOG.md
@@ -1,3 +1,25 @@
+## v0.177.0 (2026-10-29) — „Reboot guest" from the hub (D65 = A); the slow-request line and the vouch save's timing (R-1346)
+
+- **D65 = A (operator ruling 2026-10-29 15:50; `04` §6.1).** The AGENT's closed list (`hostOperatorActionNames`) is
+ now `restart_controller` and `reboot_guest` (arg: the guest's vmid, a guest of THIS host — refused before anything is
+ stored otherwise). The host page shows „Reboot guest" beside „Restart controller" for every guest of a customer host,
+ behind a confirm step (`hosts:write`, CSRF) that says every app stops for about a minute and when it is refused. The
+ agent's result („rebooted; the guest was up after 41 s, the controller answered after 63 s" / „refused: …") closes
+ the row and saves ONE `operator_action` event, as for a restart. A reboot the agent did not pick up in **30
+ minutes** expires (`HostOperatorActionRebootTTL`) — a box that was off must not reboot by itself hours later; a
+ restart keeps the day. Tests `TestHostOperatorActions_*` (the list, the refusals, the 30-minute expiry),
+ `TestHostPage_RebootGuestRendersAndPosts`, `TestHostPage_NoRebootWithoutACustomer`, `TestHandleHostDetail` (8
+ buttons) — red first, then by mutation.
+- **R-1346 — the slow-request line.** The access-log middleware writes one operator log line for a request over 5 s:
+ `[WARN] slow request: ` — the path only (no query value, no capability), the box
+ long-poll `/api/v1/wait` exempt (slow by design). `TestAccessLog_ASlowRequestIsNamed`.
+- **R-1346 — the vouch save names its time.** The hub's own access log holds seven vouch saves (POST
+ `/configuration/artifacts`) of 33–170 s since 2026-10-25, while every registry call it makes measured under 0.3 s
+ (from DooPlex and from inside the hub pod, 2026-10-29) and the page itself loads in 6 ms (in the cluster) / 65–255 ms
+ (through the public name). ingress-nginx's default 60-s read timeout turns a save over 60 s into the operator's
+ 504 while the save completes behind it. Each save now logs one line with each leg's time (registry probes, sha
+ lookups, bundle sha, fleet check + save) so the next slow save names its leg. `TestVouchSaveLogsItsTiming`.
+
## v0.176.1 (2026-10-29) — „Restart controller" for every guest of a customer host (0.176.0 showed it only with a reported controller version, which the agent does not report) — DEPLOYED on the operator's yes (image sha256:c105cb9a…, from `2ccf180b`, CI 2509)
- Found live on 0.176.0 (Tester 1's host page): no button — the agent's host report carries no controller version, so the
diff --git a/hub/internal/accesslog/accesslog.go b/hub/internal/accesslog/accesslog.go
index 6144cb85..b65c8be3 100644
--- a/hub/internal/accesslog/accesslog.go
+++ b/hub/internal/accesslog/accesslog.go
@@ -53,6 +53,8 @@ type Log struct {
keep time.Duration
now func() time.Time
logf func(format string, a ...any)
+ // slowAfter: R-1346's slow-request line.
+ slowAfter time.Duration
ch chan string
done chan struct{}
@@ -75,13 +77,25 @@ type Options struct {
Keep time.Duration
Now func() time.Time
Logf func(format string, a ...any)
+ // SlowAfter (R-1346): a request slower than this gets one operator log line (method, path, status, duration);
+ // zero → DefaultSlowAfter.
+ SlowAfter time.Duration
}
+// DefaultSlowAfter is when a request counts as slow (R-1346).
+const DefaultSlowAfter = 5 * time.Second
+
+// slowExempt are the paths slow BY DESIGN: the box's long-poll waits up to 4 minutes for a change.
+var slowExempt = map[string]bool{"/api/v1/wait": true}
+
// New starts the writer and prunes once. The directory is created on first write, never at construction, so a
// missing volume costs a logged error and nothing else.
func New(o Options) *Log {
- l := &Log{dir: o.Dir, keep: o.Keep, now: o.Now, logf: o.Logf,
+ l := &Log{dir: o.Dir, keep: o.Keep, now: o.Now, logf: o.Logf, slowAfter: o.SlowAfter,
ch: make(chan string, bufferLines), done: make(chan struct{})}
+ if l.slowAfter <= 0 {
+ l.slowAfter = DefaultSlowAfter
+ }
if l.keep <= 0 {
l.keep = DefaultKeep
}
@@ -282,7 +296,14 @@ func (l *Log) Middleware(next http.Handler) http.Handler {
e.mu.Lock()
auth := e.auth
e.mu.Unlock()
- l.enqueue(FormatLine(start, r, status, l.now().Sub(start), auth))
+ dur := l.now().Sub(start)
+ l.enqueue(FormatLine(start, r, status, dur, auth))
+ if dur > l.slowAfter && !slowExempt[r.URL.Path] {
+ // R-1346: the slow page names itself in the operator log — the path only (SafePath: no query, no
+ // capability), never a value.
+ l.logf("[WARN] slow request: %s %s %s %dms (over %s)", safeToken(r.Method, 10), SafePath(r), status,
+ dur.Milliseconds(), l.slowAfter)
+ }
}()
next.ServeHTTP(rec, r.WithContext(context.WithValue(r.Context(), ctxKey{}, e)))
finished = true
diff --git a/hub/internal/accesslog/accesslog_test.go b/hub/internal/accesslog/accesslog_test.go
index 50efd381..d9a11653 100644
--- a/hub/internal/accesslog/accesslog_test.go
+++ b/hub/internal/accesslog/accesslog_test.go
@@ -1,6 +1,7 @@
package accesslog
import (
+ "fmt"
"net/http"
"net/http/httptest"
"os"
@@ -233,3 +234,46 @@ func TestAccessLog_SkipsOnlyTheKubeletProbe(t *testing.T) {
t.Fatalf("want exactly the ingress /healthz logged, got:\n%s", got)
}
}
+
+// R-1346: a request slower than 5 s gets one operator log line — method, path (no query value), status, duration — so a
+// slow page names itself in the hub's log. The box long-poll (/api/v1/wait) is slow by design and is not named.
+// RED-PROOF: drop the slow check → no line → FAILS; drop the wait exception → the 240 s poll is named → FAILS.
+func TestAccessLog_ASlowRequestIsNamed(t *testing.T) {
+ c := &clock{t: time.Date(2026, 10, 29, 13, 3, 19, 0, time.UTC)}
+ var mu sync.Mutex
+ var lines []string
+ l := New(Options{Dir: t.TempDir(), Now: c.now, Logf: func(f string, a ...any) {
+ mu.Lock()
+ defer mu.Unlock()
+ lines = append(lines, fmt.Sprintf(f, a...))
+ }})
+ defer l.Close()
+ took := 0 * time.Second
+ h := l.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c.set(c.now().Add(took))
+ w.WriteHeader(http.StatusSeeOther)
+ }))
+ took = 170 * time.Second
+ serve(h, httptest.NewRequest(http.MethodPost, "/configuration/artifacts?agent_version=0.169.0", nil))
+ took = 4 * time.Second
+ serve(h, httptest.NewRequest(http.MethodGet, "/customers/tester-1", nil))
+ took = 240 * time.Second
+ serve(h, httptest.NewRequest(http.MethodGet, "/api/v1/wait?gen=7", nil))
+ mu.Lock()
+ defer mu.Unlock()
+ var slow []string
+ for _, ln := range lines {
+ if strings.Contains(ln, "slow request") {
+ slow = append(slow, ln)
+ }
+ }
+ if len(slow) != 1 {
+ t.Fatalf("slow lines = %q, want exactly the 170 s save", slow)
+ }
+ if want := "[WARN] slow request: POST /configuration/artifacts 303 170000ms"; !strings.HasPrefix(slow[0], want) {
+ t.Fatalf("line %q, want prefix %q", slow[0], want)
+ }
+ if strings.Contains(slow[0], "0.169.0") {
+ t.Fatalf("a query value reached the log: %q", slow[0])
+ }
+}
diff --git a/hub/internal/store/opactions.go b/hub/internal/store/opactions.go
index bfa104cb..7f99625c 100644
--- a/hub/internal/store/opactions.go
+++ b/hub/internal/store/opactions.go
@@ -38,6 +38,13 @@ const (
// HostOperatorActionRestartController (operator brief 2026-10-29, Part 1): the AGENT restarts a guest's controller
// (the supervisor's own act and guards). The agent's list holds this one verb.
HostOperatorActionRestartController = "restart_controller"
+ // HostOperatorActionRebootGuest (D65 = A, operator ruling 2026-10-29 15:50; `04` §6.1): the AGENT reboots one guest
+ // (`pct reboot` of THAT guest, never the host) with its own guards — no backup, restore, update or swap; at most
+ // once an hour; the restart brake. No signature: a reboot destroys nothing.
+ HostOperatorActionRebootGuest = "reboot_guest"
+ // HostOperatorActionRebootTTL: a reboot the agent did not pick up in 30 minutes expires — a box that was off must not
+ // reboot by itself when it comes back hours later.
+ HostOperatorActionRebootTTL = 30 * time.Minute
// Outcomes: the box's three, plus two the hub sets itself.
OperatorActionDone = "done"
@@ -64,7 +71,7 @@ var operatorActionNames = []string{OperatorActionOffsiteBackupNow, OperatorActio
// hostOperatorActionNames is the AGENT's closed list (the host report's reply). Pinned by
// TestHostOperatorActions_ClosedList; never shares a verb with the controller's list.
-var hostOperatorActionNames = []string{HostOperatorActionRestartController}
+var hostOperatorActionNames = []string{HostOperatorActionRestartController, HostOperatorActionRebootGuest}
// stackNameRe is an app's stack name as the controller names it (a catalog slug).
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
@@ -75,11 +82,11 @@ func HostOperatorActionNames() []string { return sortedStrings(hostOperatorActio
// IsHostOperatorAction reports whether the verb belongs to the agent's list.
func IsHostOperatorAction(action string) bool { return inList(hostOperatorActionNames, action) }
-// ValidateHostOperatorAction refuses anything outside the agent's closed list. restart_controller takes the guest's
-// vmid (Proxmox: 100–999999999).
+// ValidateHostOperatorAction refuses anything outside the agent's closed list. Both verbs take the guest's vmid
+// (Proxmox: 100–999999999).
func ValidateHostOperatorAction(action, arg string) error {
switch action {
- case HostOperatorActionRestartController:
+ case HostOperatorActionRestartController, HostOperatorActionRebootGuest:
v, err := strconv.Atoi(arg)
if err != nil || strconv.Itoa(v) != arg || v < 100 || v > 999999999 {
return fmt.Errorf("the guest id must be a number 100-999999999")
@@ -215,6 +222,12 @@ func (s *Store) PendingHostOperatorActions(hostID string) ([]OperatorActionDirec
now, OperatorActionExpired, "the agent did not answer within a day", hostID, now.Add(-OperatorActionTTL)); err != nil {
return nil, err
}
+ if _, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
+ WHERE host_id = ? AND action = ? AND done_at IS NULL AND requested_at < ?`,
+ now, OperatorActionExpired, "the agent did not pick the reboot up within 30 minutes — press again if it is still needed",
+ hostID, HostOperatorActionRebootGuest, now.Add(-HostOperatorActionRebootTTL)); err != nil {
+ return nil, err
+ }
rows, err := s.db.Query(`SELECT id, action, arg FROM operator_actions
WHERE host_id = ? AND done_at IS NULL ORDER BY id LIMIT ?`, hostID, operatorActionsListed)
if err != nil {
diff --git a/hub/internal/store/opactions_test.go b/hub/internal/store/opactions_test.go
index 031cd33d..dc0598c5 100644
--- a/hub/internal/store/opactions_test.go
+++ b/hub/internal/store/opactions_test.go
@@ -2,6 +2,7 @@ package store
import (
"reflect"
+ "strings"
"testing"
"time"
)
@@ -110,9 +111,10 @@ func TestOperatorActions_ExpireAndResetCancel(t *testing.T) {
}
}
-// The AGENT's closed list (operator brief 2026-10-29, Part 1): one verb. RED-PROOF: seen failing before the list existed.
+// The AGENT's closed list (operator brief 2026-10-29, Part 1; D65 = A adds reboot_guest). RED-PROOF: seen failing before
+// the list existed, and again before reboot_guest joined it.
func TestHostOperatorActions_ClosedList(t *testing.T) {
- if got, want := HostOperatorActionNames(), []string{"restart_controller"}; !reflect.DeepEqual(got, want) {
+ if got, want := HostOperatorActionNames(), []string{"reboot_guest", "restart_controller"}; !reflect.DeepEqual(got, want) {
t.Fatalf("host operator actions = %v, want exactly %v — a new verb needs the operator's word", got, want)
}
for _, a := range OperatorActionNames() {
@@ -127,8 +129,8 @@ func TestHostOperatorActions_RefusedBeforeStoring(t *testing.T) {
s := newTestStore(t)
bad := []struct{ action, arg string }{
{"restart_controller", ""}, {"restart_controller", "abc"}, {"restart_controller", "99"}, {"restart_controller", "9201 "},
- {"restart_controller", "-1"}, {"restart_controller", "10000000000"}, {"reboot_guest", "9201"}, {"clear_hold", "radicale"},
- {"offsite_backup_now", ""},
+ {"restart_controller", "-1"}, {"restart_controller", "10000000000"}, {"reboot_host", "9201"}, {"clear_hold", "radicale"},
+ {"offsite_backup_now", ""}, {"reboot_guest", ""}, {"reboot_guest", "abc"}, {"reboot_guest", "99"},
}
for _, b := range bad {
if _, err := s.CreateHostOperatorAction("c1", "h1", b.action, b.arg, "operator"); err == nil {
@@ -147,6 +149,31 @@ func TestHostOperatorActions_RefusedBeforeStoring(t *testing.T) {
if _, err := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator"); err != nil {
t.Fatalf("restart_controller 9201 refused: %v", err)
}
+ if _, err := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator"); err != nil {
+ t.Fatalf("reboot_guest 9201 refused: %v", err)
+ }
+ if _, err := s.CreateOperatorAction("c1", "reboot_guest", "9201", "operator"); err == nil {
+ t.Error("reboot_guest reached the controller's list")
+ }
+}
+
+// D65 = A: a reboot the agent has not picked up in 30 minutes expires — a box that was off must not reboot by itself
+// when it comes back hours later. A restart keeps the day. RED-PROOF: drop the reboot TTL → the 31-minute-old reboot is
+// still listed → FAILS.
+func TestHostOperatorActions_ARebootExpiresAfter30Minutes(t *testing.T) {
+ s := newTestStore(t)
+ rb, _ := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator")
+ rs, _ := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator")
+ if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id IN (?, ?)`, time.Now().UTC().Add(-31*time.Minute), rb, rs); err != nil {
+ t.Fatal(err)
+ }
+ p, _ := s.PendingHostOperatorActions("h1")
+ if len(p) != 1 || p[0].ID != rs {
+ t.Fatalf("pending = %+v, want only the restart #%d", p, rs)
+ }
+ if r, _ := s.getOperatorAction(rb); r == nil || r.Outcome != OperatorActionExpired || !strings.Contains(r.Message, "30 minutes") {
+ t.Fatalf("the old reboot = %+v, want expired, naming 30 minutes", r)
+ }
}
// An agent row reaches only its host's envelope, a controller row only the customer's ACK; a result closes only a row of
diff --git a/hub/internal/web/agent_only_vouch_test.go b/hub/internal/web/agent_only_vouch_test.go
index cdcbea58..ea3b1b7e 100644
--- a/hub/internal/web/agent_only_vouch_test.go
+++ b/hub/internal/web/agent_only_vouch_test.go
@@ -1,9 +1,12 @@
package web
import (
+ "bytes"
+ "log"
"net/http"
"net/http/httptest"
"net/url"
+ "regexp"
"strings"
"testing"
@@ -80,3 +83,19 @@ func TestVouchGoldenChangedToOlderIsStillRefused(t *testing.T) {
}
}
}
+
+// R-1346: a vouch save names the time of each leg in one line, so the next slow save says where its time went.
+// RED-PROOF: drop the timing line → FAILS.
+func TestVouchSaveLogsItsTiming(t *testing.T) {
+ s, st := newTestServer(t)
+ var logs bytes.Buffer
+ s.logger = log.New(&logs, "", 0)
+ seedReport(t, st, "demo", "0.335.0")
+ w := postArtifacts(t, s, url.Values{"agent_version": {"0.160.0"}, "agent_sha256": {strings.Repeat("c", 64)}})
+ if loc := w.Header().Get("Location"); !strings.Contains(loc, "flash=artifacts_set") {
+ t.Fatalf("vouch refused: %q", loc)
+ }
+ if !regexp.MustCompile(`artifact vouch timing \(R-1346\): registry probes \d+ms, sha lookups \d+ms, bundle sha \d+ms, fleet check \+ save \d+ms, total \d+ms`).MatchString(logs.String()) {
+ t.Fatalf("no timing line:\n%s", logs.String())
+ }
+}
diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go
index 909b4f3b..91db6f75 100644
--- a/hub/internal/web/configs.go
+++ b/hub/internal/web/configs.go
@@ -1440,6 +1440,9 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
// read as a success, and this project has the scars; so an unreachable registry refuses too,
// with its own message. There is NO OVERRIDE: the registry is the operator's own server, and if
// it cannot be reached then vouching is moot rather than urgent.
+ // R-1346: each leg's time, in one line at the save — the save took 37–170 s on four days (the hub's access log) while
+ // every registry call measured under 0.3 s, so the next slow vouch must name its leg.
+ began := time.Now()
if s.gitea != nil {
ctx := r.Context()
type probeTarget struct{ label, pkg, file, version, repo string }
@@ -1484,6 +1487,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
}
}
+ probesDone := time.Now()
// R-292: each failure names its own cause — version missing, registry unreachable, no sha listed, or a
// bad typed sha — instead of one flash that conflated all of them.
agentSHA, flashAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
@@ -1492,6 +1496,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
return
}
+ shaDone := time.Now()
// R-840: the vouched agent's CONFIG BUNDLE is resolved from the registry by exact name, like the binary. A version
// without one (older than v0.143.0) vouches none — the installer then falls back to its per-file fetches.
bundleSHA := ""
@@ -1504,6 +1509,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
}
bundleSHA = b
}
+ bundleDone := time.Now()
// R-50b(a): the PBS-DR wrapper hash is operator-typed, not resolved from the package registry —
// unlike the agent binary and the golden, this artifact is not published there at all. It is
// installed from raw/branch/main, which is exactly the drift this field makes visible.
@@ -1575,6 +1581,9 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
return
}
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q wrapper_sha=%t bundle_sha=%q", agentVer, goldenVer, minAgent, wrapperSHA != "", bundleSHA)
+ s.logger.Printf("[INFO] artifact vouch timing (R-1346): registry probes %dms, sha lookups %dms, bundle sha %dms, fleet check + save %dms, total %dms",
+ probesDone.Sub(began).Milliseconds(), shaDone.Sub(probesDone).Milliseconds(), bundleDone.Sub(shaDone).Milliseconds(),
+ time.Since(bundleDone).Milliseconds(), time.Since(began).Milliseconds())
// Agent-plane immediate-sync (Direction-2a, v0.59.0): a MinAgent-floor / vouched-agent change is
// a fleet-wide agent-plane intent shift. Fire-and-forget nudge every box so it re-reports at
// once (the self-update train's signed op / floor re-evaluation lands in seconds, not ≤15 min).
diff --git a/hub/internal/web/host_opactions_test.go b/hub/internal/web/host_opactions_test.go
index 0939c593..da51275c 100644
--- a/hub/internal/web/host_opactions_test.go
+++ b/hub/internal/web/host_opactions_test.go
@@ -84,7 +84,8 @@ func TestHostPage_RestartAndClearHoldPosts(t *testing.T) {
{"action": {"clear_hold"}, "arg": {"vikunja"}}, // not held
{"action": {"restart_controller"}, "arg": {"9202"}}, // not a guest of this host
{"action": {"restart_controller"}, "arg": {"abc"}},
- {"action": {"reboot_guest"}, "arg": {"9201"}},
+ {"action": {"reboot_host"}, "arg": {"9201"}},
+ {"action": {"reboot_guest"}, "arg": {"9202"}}, // not a guest of this host
} {
if rr := postOperatorAction(t, s, "h1", f); rr.Code != http.StatusBadRequest {
t.Errorf("%v: status %d, want 400", f, rr.Code)
@@ -122,3 +123,40 @@ func TestOperatorActor_NamesTheAccessToken(t *testing.T) {
t.Fatalf("actor = %q", got)
}
}
+
+// D65 = A: „Reboot guest" beside „Restart controller", behind a confirm step that says the apps stop for about a minute;
+// the press stores a row for THIS host's agent only. RED-PROOF: seen failing before the button and the route existed.
+func TestHostPage_RebootGuestRendersAndPosts(t *testing.T) {
+ s, st := heldFixture(t)
+ body := renderHost(t, s, "h1")
+ for _, want := range []string{`data-reboot-guest="9201"`, `name="action" value="reboot_guest"`,
+ "Reboot guest 9201 on h1 (c1)?", "every app of the household stops for about a minute"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("host page lacks %q", want)
+ }
+ }
+ if rr := postOperatorAction(t, s, "h1", url.Values{"action": {"reboot_guest"}, "arg": {"9201"}}); rr.Code != http.StatusSeeOther {
+ t.Fatalf("reboot: %d %s", rr.Code, rr.Body.String())
+ }
+ if a, _ := st.PendingHostOperatorActions("h1"); len(a) != 1 || a[0].Action != "reboot_guest" || a[0].Arg != "9201" {
+ t.Fatalf("the agent's reply would list %+v", a)
+ }
+ if c, _ := st.PendingOperatorActions("c1"); len(c) != 0 {
+ t.Fatalf("the reboot reached the controller's ACK: %+v", c)
+ }
+}
+
+// A host with no customer has no buttons (no controller, no household) — the other branch of the template gate.
+func TestHostPage_NoRebootWithoutACustomer(t *testing.T) {
+ s, st := newTestServer(t)
+ if err := st.UpsertHost(&store.Host{HostID: "h9", APIKey: "k"}); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ if err := st.UpsertGuestFromReport(&store.Guest{GuestID: "h9-9201", HostID: "h9", VMID: 9201, Status: "running", LastSeenAt: &now}); err != nil {
+ t.Fatal(err)
+ }
+ if body := renderHost(t, s, "h9"); strings.Contains(body, "data-reboot-guest") {
+ t.Fatal("a host with no customer shows Reboot guest")
+ }
+}
diff --git a/hub/internal/web/hosts_test.go b/hub/internal/web/hosts_test.go
index 11e3bdb0..fed59f0a 100644
--- a/hub/internal/web/hosts_test.go
+++ b/hub/internal/web/hosts_test.go
@@ -176,12 +176,15 @@ func TestHandleHostDetail(t *testing.T) {
// remains absent: 2 log requests + 4 operator actions, and the 4 all post to /operator-action.
// 2026-10-29 (operator brief, Part 1): plus ONE „Restart controller" per guest of a customer host (the AGENT's closed
// list, behind a confirm step; this fixture has one guest). „Clear hold" appears only per held app (none
- // here). No other button.
- if got := strings.Count(strings.ToLower(body), "
+
+ {{/* D65 = A (2026-10-29): the AGENT reboots this guest (pct reboot — never the host). Refused during a backup, restore, update or swap, more than once an hour, or under the restart brake; the result says when the guest and the controller were back. */}}
+
+ Reboot guest
+
{{end}}