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), "Restart the controller of guest {{.VMID}} on {{$.HostID}} ({{$.CustomerID}})? The box's agent restarts it, even when the controller does not answer. The household's dashboard is away for about a minute. At most 3 restarts in 15 minutes; the result shows under Operator Actions.

+ + {{/* 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 +
+ + + +

Reboot guest {{.VMID}} on {{$.HostID}} ({{$.CustomerID}})? The box's agent reboots the whole guest — use it when Docker or the network inside the box is stuck. While it reboots, every app of the household stops for about a minute; the dashboard then says Felhom support restarted the box. Refused during a backup, a restore, an update or a controller swap, and more than once an hour. The result shows under Operator Actions.

+ +
{{end}} {{timeAgoPtr .LastSeenAt}}