hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185)
Host page "Operator Actions" card: run off-site backup now, run a check now
(fixed job list), stop / extend (1-30 days) a deletion countdown. POST
/hosts/{id}/operator-action validates against the CLOSED list before
storing (unknown -> 400, no row), stores operator_actions(id, customer_id,
action, arg, requested_at, requested_by, done_at, outcome, message), logs
who pressed (channel + address) and bumps the box's intent. The report ACK
lists pending rows as operator_actions until the box's
operator_action_results closes them (matched on id AND reporting
customer); each closed row becomes a hub-minted operator_action event
(stored, never dispatched). Unanswered after 24 h: expired. A customer
RESET cancels pending rows. Wire gate: new root + field-by-field mirror
(controller report.OperatorAction) — needs the controller commit first.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -481,6 +481,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Operator actions (R-314/R-279/R-177, `09` §3 decision 185): the box's results for the actions
|
||||
// the ACK listed. Each closes its row — matched on the id AND this report's customer, so a result
|
||||
// naming another customer's action changes nothing — and becomes a hub-minted event (stored only,
|
||||
// never dispatched: an operator record, not a customer mail). Old controllers never send this.
|
||||
h.ingestOperatorActionResults(payload.CustomerID, body)
|
||||
|
||||
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
|
||||
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
|
||||
var drPayload struct {
|
||||
@@ -609,6 +615,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
resp["controller_log_requested"] = true
|
||||
}
|
||||
|
||||
// Decision 185: the operator's pending actions for this box, listed until each result arrives.
|
||||
// Read AFTER the results above were recorded, so an action answered in this report is not
|
||||
// listed back in the same reply. Omitted when none (an old controller ignores the field).
|
||||
if acts, err := h.store.PendingOperatorActions(payload.CustomerID); err != nil {
|
||||
h.logger.Printf("[WARN] operator actions for %s could not be read (not listed this cycle): %v", payload.CustomerID, err)
|
||||
} else if len(acts) > 0 {
|
||||
resp["operator_actions"] = acts
|
||||
h.logger.Printf("[DEBUG] operator actions listed for %s: %d", payload.CustomerID, len(acts))
|
||||
}
|
||||
|
||||
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
|
||||
// else global default) and the latest available version. The controller compares its current
|
||||
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// operatorActionResultsPayload is the controller report's operator_action_results (decision 185;
|
||||
// controller internal/report OperatorActionResult).
|
||||
type operatorActionResultsPayload struct {
|
||||
OperatorActionResults []struct {
|
||||
ID int64 `json:"id"`
|
||||
Outcome string `json:"outcome"`
|
||||
Message string `json:"message"`
|
||||
} `json:"operator_action_results"`
|
||||
}
|
||||
|
||||
// ingestOperatorActionResults closes each answered row and saves one hub-minted event per closed row.
|
||||
// The box re-sends a result until the ACK stops listing its id; a re-send finds the row closed and
|
||||
// does nothing (RecordOperatorActionResult matches only open rows of THIS customer).
|
||||
func (h *Handler) ingestOperatorActionResults(customerID string, body []byte) {
|
||||
var p operatorActionResultsPayload
|
||||
if err := json.Unmarshal(body, &p); err != nil || len(p.OperatorActionResults) == 0 {
|
||||
return
|
||||
}
|
||||
for _, res := range p.OperatorActionResults {
|
||||
row, err := h.store.RecordOperatorActionResult(customerID, res.ID, res.Outcome, res.Message)
|
||||
switch {
|
||||
case err != nil:
|
||||
h.logger.Printf("[WARN] operator action #%d result from %s not recorded: %v", res.ID, customerID, err)
|
||||
continue
|
||||
case row == nil:
|
||||
h.logger.Printf("[DEBUG] operator action #%d result from %s ignored (already closed, or not this customer's)", res.ID, customerID)
|
||||
continue
|
||||
}
|
||||
h.logger.Printf("[INFO] operator action #%d %s%s for %s (pressed by %s): %s — %s",
|
||||
row.ID, row.Action, argSuffix(row.Arg), customerID, row.RequestedBy, row.Outcome, row.Message)
|
||||
severity := "info"
|
||||
if row.Outcome != store.OperatorActionDone {
|
||||
severity = "warning"
|
||||
}
|
||||
if _, eerr := h.store.SaveEvent(customerID, "operator_action", severity,
|
||||
fmt.Sprintf("Operator action %s%s (pressed by %s): %s — %s", row.Action, argSuffix(row.Arg), row.RequestedBy, row.Outcome, row.Message),
|
||||
"", "hub"); eerr != nil {
|
||||
h.logger.Printf("[WARN] operator action #%d recorded, but its event could not be saved: %v", row.ID, eerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func argSuffix(arg string) string {
|
||||
if arg == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + arg
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1), the design's red test (4), wire half: the report reply lists a pending
|
||||
// operator action until a result arrives, then not; a result naming another customer's id is ignored;
|
||||
// a closed result becomes a hub-minted event.
|
||||
|
||||
func ackActions(t *testing.T, h *Handler, customerID, extra string) []store.OperatorActionDirective {
|
||||
t.Helper()
|
||||
body := `{"customer_id":"` + customerID + `"` + extra + `}`
|
||||
rr := do(h, http.MethodPost, "/report", globalKey, body)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("report: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var ack struct {
|
||||
OperatorActions []store.OperatorActionDirective `json:"operator_actions"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ack.OperatorActions
|
||||
}
|
||||
|
||||
func TestReportACK_OperatorActionsUntilResult(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
id, err := st.CreateOperatorAction("c1", "offsite_backup_now", "", "operator browser session from 10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Listed to c1, not to c2.
|
||||
if got := ackActions(t, h, "c1", ""); len(got) != 1 || got[0].ID != id || got[0].Action != "offsite_backup_now" {
|
||||
t.Fatalf("c1 ACK = %+v", got)
|
||||
}
|
||||
if got := ackActions(t, h, "c2", ""); len(got) != 0 {
|
||||
t.Fatalf("c2 sees c1's action: %+v", got)
|
||||
}
|
||||
// c2 reports a result for c1's id: ignored — c1's ACK still lists it, and no event is saved.
|
||||
res := fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"forged"}]`, id)
|
||||
ackActions(t, h, "c2", res)
|
||||
if got := ackActions(t, h, "c1", ""); len(got) != 1 {
|
||||
t.Fatalf("a cross-customer result closed c1's action: %+v", got)
|
||||
}
|
||||
if evs, _ := st.GetRecentEvents("c2", 10); len(evs) != 0 {
|
||||
t.Fatalf("a cross-customer result minted an event: %+v", evs)
|
||||
}
|
||||
// c1's own result: closed, and THE SAME reply no longer lists it.
|
||||
res = fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"the off-site backup finished"}]`, id)
|
||||
if got := ackActions(t, h, "c1", res); len(got) != 0 {
|
||||
t.Fatalf("still listed in the reply to its own result: %+v", got)
|
||||
}
|
||||
evs, _ := st.GetRecentEvents("c1", 10)
|
||||
if len(evs) != 1 || evs[0].EventType != "operator_action" || evs[0].Source != "hub" ||
|
||||
!strings.Contains(evs[0].Message, "offsite_backup_now") || !strings.Contains(evs[0].Message, "10.0.0.1") {
|
||||
t.Fatalf("events = %+v", evs)
|
||||
}
|
||||
// The box re-sends until it sees the id gone: a no-op, no second event.
|
||||
ackActions(t, h, "c1", res)
|
||||
if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 1 {
|
||||
t.Fatalf("a re-sent result minted %d events", len(evs))
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,13 @@ func (s *Store) PurgeCustomerResetDBState(customerID string, escrowAcked bool) e
|
||||
return fmt.Errorf("reset purge %q: %w", q, err)
|
||||
}
|
||||
}
|
||||
// Decision 185: an operator action still pending for the OLD box must never reach the box that
|
||||
// takes this customer id next. Closed, not deleted — the row is the record of who pressed what.
|
||||
if _, err := tx.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
|
||||
WHERE customer_id = ? AND done_at IS NULL`,
|
||||
time.Now().UTC(), OperatorActionCancelled, "cancelled by the customer RESET", customerID); err != nil {
|
||||
return fmt.Errorf("reset: cancel pending operator actions: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Operator actions (R-314 / R-279 / R-177, `09` §3 decision 185 — D1, design option A of
|
||||
// documentation/audits/day-2026-10-08/design-R-314-279-177.md).
|
||||
//
|
||||
// The operator presses a button on the host page; a row is stored here and the box's intent generation
|
||||
// is bumped, so its wait channel wakes; the report ACK lists every pending row as
|
||||
// `operator_actions: [{id, action, arg}]`; the controller acts once per id and sends
|
||||
// `operator_action_results: [{id, outcome, message}]` on its next report; the row is closed and stops
|
||||
// being listed. The hub never connects into the box.
|
||||
//
|
||||
// THE LIST IS CLOSED, and the hub refuses an unknown action, job or argument BEFORE storing anything —
|
||||
// the controller refuses them again on its side. Nothing on the list deletes data, starts a countdown or
|
||||
// shortens one; `03` §4 asks for a signing key only to destroy or overwrite the only copy. The list is
|
||||
// pinned on both sides (TestOperatorActions_ClosedList here, TestOpActions_ClosedList in the controller)
|
||||
// so a new entry is an operator decision, not an edit.
|
||||
|
||||
const (
|
||||
OperatorActionOffsiteBackupNow = "offsite_backup_now"
|
||||
OperatorActionAbandonStop = "abandon_stop"
|
||||
OperatorActionAbandonExtend = "abandon_extend"
|
||||
OperatorActionRunJob = "run_job"
|
||||
|
||||
// Outcomes: the box's three, plus two the hub sets itself.
|
||||
OperatorActionDone = "done"
|
||||
OperatorActionRefused = "refused"
|
||||
OperatorActionFailed = "failed"
|
||||
OperatorActionExpired = "expired" // the box did not answer within OperatorActionTTL
|
||||
OperatorActionCancelled = "cancelled" // the customer was RESET while it was pending
|
||||
|
||||
OperatorActionExtendMinDays = 1
|
||||
OperatorActionExtendMaxDays = 30
|
||||
|
||||
// OperatorActionTTL: a pending action the box has not answered in a day is closed as expired and
|
||||
// no longer listed — an off-site run pressed for a box that was off for a week must not start the
|
||||
// moment it comes back, unasked.
|
||||
OperatorActionTTL = 24 * time.Hour
|
||||
|
||||
// operatorActionMessageMax bounds the box's message stored per row.
|
||||
operatorActionMessageMax = 500
|
||||
// operatorActionsListed caps how many pending rows one ACK carries.
|
||||
operatorActionsListed = 10
|
||||
)
|
||||
|
||||
var operatorActionNames = []string{OperatorActionOffsiteBackupNow, OperatorActionAbandonStop, OperatorActionAbandonExtend, OperatorActionRunJob}
|
||||
|
||||
// operatorJobNames is the fixed set run_job may name — the controller's scheduler job names.
|
||||
var operatorJobNames = []string{"fill-watch", "offsite-integrity", "offsite-proof", "disk-health-check"}
|
||||
|
||||
// OperatorActionNames returns the closed action list (sorted copy).
|
||||
func OperatorActionNames() []string { return sortedStrings(operatorActionNames) }
|
||||
|
||||
// OperatorJobNames returns the fixed run_job set (sorted copy).
|
||||
func OperatorJobNames() []string { return sortedStrings(operatorJobNames) }
|
||||
|
||||
func sortedStrings(in []string) []string {
|
||||
out := append([]string(nil), in...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func inList(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateOperatorAction refuses anything outside the closed list. A nil error is the only way a row
|
||||
// is stored.
|
||||
func ValidateOperatorAction(action, arg string) error {
|
||||
switch action {
|
||||
case OperatorActionOffsiteBackupNow, OperatorActionAbandonStop:
|
||||
if arg != "" {
|
||||
return fmt.Errorf("%s takes no argument", action)
|
||||
}
|
||||
case OperatorActionAbandonExtend:
|
||||
d, err := strconv.Atoi(arg)
|
||||
if err != nil || d < OperatorActionExtendMinDays || d > OperatorActionExtendMaxDays {
|
||||
return fmt.Errorf("the number of days must be %d-%d", OperatorActionExtendMinDays, OperatorActionExtendMaxDays)
|
||||
}
|
||||
case OperatorActionRunJob:
|
||||
if !inList(operatorJobNames, arg) {
|
||||
return fmt.Errorf("unknown job %q", arg)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OperatorActionDirective is ONE entry of the report ACK's operator_actions list — the wire type,
|
||||
// named so scripts/wire_contract_gate.py can check it field by field against the controller's
|
||||
// report.OperatorAction.
|
||||
type OperatorActionDirective struct {
|
||||
ID int64 `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Arg string `json:"arg,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorActionRow is one stored row, for the host page.
|
||||
type OperatorActionRow struct {
|
||||
ID int64
|
||||
CustomerID string
|
||||
Action string
|
||||
Arg string
|
||||
RequestedAt time.Time
|
||||
RequestedBy string
|
||||
DoneAt *time.Time
|
||||
Outcome string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Store) migrateOperatorActions() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS operator_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
arg TEXT NOT NULL DEFAULT '',
|
||||
requested_at DATETIME NOT NULL,
|
||||
requested_by TEXT NOT NULL DEFAULT '',
|
||||
done_at DATETIME,
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_operator_actions_customer ON operator_actions(customer_id, done_at);`)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateOperatorAction validates against the closed list and stores a pending row. by names who
|
||||
// pressed (the operator's channel and address — never a credential).
|
||||
func (s *Store) CreateOperatorAction(customerID, action, arg, by string) (int64, error) {
|
||||
if customerID == "" {
|
||||
return 0, fmt.Errorf("operator action: empty customer id")
|
||||
}
|
||||
if err := ValidateOperatorAction(action, arg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.db.Exec(`INSERT INTO operator_actions (customer_id, action, arg, requested_at, requested_by)
|
||||
VALUES (?, ?, ?, ?, ?)`, customerID, action, arg, time.Now().UTC(), by)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// PendingOperatorActions returns what the next ACK lists for this customer, oldest first. Rows older
|
||||
// than OperatorActionTTL are closed as expired first and are not listed.
|
||||
func (s *Store) PendingOperatorActions(customerID string) ([]OperatorActionDirective, error) {
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
|
||||
WHERE customer_id = ? AND done_at IS NULL AND requested_at < ?`,
|
||||
now, OperatorActionExpired, "the box did not answer within a day", customerID, now.Add(-OperatorActionTTL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT id, action, arg FROM operator_actions
|
||||
WHERE customer_id = ? AND done_at IS NULL ORDER BY id LIMIT ?`, customerID, operatorActionsListed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OperatorActionDirective
|
||||
for rows.Next() {
|
||||
var d OperatorActionDirective
|
||||
if err := rows.Scan(&d.ID, &d.Action, &d.Arg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordOperatorActionResult closes a pending row with the box's result. It matches on BOTH the id and
|
||||
// the reporting customer, so a result naming another customer's action changes nothing (recorded=false).
|
||||
// An outcome outside the box's three is refused. Returns the closed row when recorded.
|
||||
func (s *Store) RecordOperatorActionResult(customerID string, id int64, outcome, message string) (*OperatorActionRow, error) {
|
||||
switch outcome {
|
||||
case OperatorActionDone, OperatorActionRefused, OperatorActionFailed:
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown outcome %q", outcome)
|
||||
}
|
||||
if r := []rune(message); len(r) > operatorActionMessageMax {
|
||||
message = string(r[:operatorActionMessageMax])
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
|
||||
WHERE id = ? AND customer_id = ? AND done_at IS NULL`, time.Now().UTC(), outcome, message, id, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.getOperatorAction(id)
|
||||
}
|
||||
|
||||
func (s *Store) getOperatorAction(id int64) (*OperatorActionRow, error) {
|
||||
rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message
|
||||
FROM operator_actions WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list, err := scanOperatorActions(rows)
|
||||
if err != nil || len(list) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// ListOperatorActions returns the customer's newest rows (pending and closed), newest first.
|
||||
func (s *Store) ListOperatorActions(customerID string, limit int) ([]OperatorActionRow, error) {
|
||||
rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message
|
||||
FROM operator_actions WHERE customer_id = ? ORDER BY id DESC LIMIT ?`, customerID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanOperatorActions(rows)
|
||||
}
|
||||
|
||||
func scanOperatorActions(rows *sql.Rows) ([]OperatorActionRow, error) {
|
||||
var out []OperatorActionRow
|
||||
for rows.Next() {
|
||||
var r OperatorActionRow
|
||||
var done sql.NullTime
|
||||
if err := rows.Scan(&r.ID, &r.CustomerID, &r.Action, &r.Arg, &r.RequestedAt, &r.RequestedBy, &done, &r.Outcome, &r.Message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if done.Valid {
|
||||
t := done.Time
|
||||
r.DoneAt = &t
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1). See opactions.go.
|
||||
|
||||
// The list is CLOSED, on the hub side too — this fails when an action or a job is added, by design. The
|
||||
// controller pins the same four (internal/report TestOpActions_ClosedList).
|
||||
func TestOperatorActions_ClosedList(t *testing.T) {
|
||||
if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "offsite_backup_now", "run_job"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("operator actions = %v, want exactly %v — a new action needs the operator's word (decision 185)", got, want)
|
||||
}
|
||||
if got, want := OperatorJobNames(), []string{"disk-health-check", "fill-watch", "offsite-integrity", "offsite-proof"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("run_job names = %v, want exactly %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorActions_RefusedBeforeStoring(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
bad := []struct{ action, arg string }{
|
||||
{"delete_everything", ""},
|
||||
{"run_job", "offsite-abandon-sweep"},
|
||||
{"run_job", ""},
|
||||
{"abandon_extend", "0"},
|
||||
{"abandon_extend", "31"},
|
||||
{"abandon_extend", "x"},
|
||||
{"abandon_stop", "1"},
|
||||
{"offsite_backup_now", "now"},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if _, err := s.CreateOperatorAction("c1", b.action, b.arg, "operator"); err == nil {
|
||||
t.Errorf("%s(%q) was accepted", b.action, b.arg)
|
||||
}
|
||||
}
|
||||
if rows, _ := s.ListOperatorActions("c1", 50); len(rows) != 0 {
|
||||
t.Fatalf("a refused action stored %d row(s)", len(rows))
|
||||
}
|
||||
for _, ok := range []struct{ action, arg string }{{"offsite_backup_now", ""}, {"abandon_stop", ""}, {"abandon_extend", "1"}, {"abandon_extend", "30"}, {"run_job", "fill-watch"}} {
|
||||
if _, err := s.CreateOperatorAction("c1", ok.action, ok.arg, "operator"); err != nil {
|
||||
t.Errorf("%s(%q) refused: %v", ok.action, ok.arg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Red test (4), store half: listed until a result arrives, then not; a result naming another
|
||||
// customer's id changes nothing.
|
||||
func TestOperatorActions_ListedUntilResult_OtherCustomerIgnored(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
id, err := s.CreateOperatorAction("c1", "run_job", "fill-watch", "operator session from 10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := s.PendingOperatorActions("c1")
|
||||
if len(p) != 1 || p[0] != (OperatorActionDirective{ID: id, Action: "run_job", Arg: "fill-watch"}) {
|
||||
t.Fatalf("pending = %+v", p)
|
||||
}
|
||||
if p2, _ := s.PendingOperatorActions("c2"); len(p2) != 0 {
|
||||
t.Fatalf("another customer sees c1's action: %+v", p2)
|
||||
}
|
||||
// c2 reports a result for c1's id → ignored.
|
||||
if row, err := s.RecordOperatorActionResult("c2", id, "done", "x"); err != nil || row != nil {
|
||||
t.Fatalf("cross-customer result recorded: row=%+v err=%v", row, err)
|
||||
}
|
||||
if p, _ := s.PendingOperatorActions("c1"); len(p) != 1 {
|
||||
t.Fatal("a cross-customer result closed the row")
|
||||
}
|
||||
if _, err := s.RecordOperatorActionResult("c1", id, "exploded", "x"); err == nil {
|
||||
t.Fatal("an unknown outcome was accepted")
|
||||
}
|
||||
row, err := s.RecordOperatorActionResult("c1", id, "done", "the job fill-watch ran")
|
||||
if err != nil || row == nil || row.Outcome != "done" || row.DoneAt == nil || row.RequestedBy != "operator session from 10.0.0.1" {
|
||||
t.Fatalf("record: row=%+v err=%v", row, err)
|
||||
}
|
||||
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
|
||||
t.Fatalf("still listed after its result: %+v", p)
|
||||
}
|
||||
// A repeated result (the box re-sends until it sees the id gone) is a no-op.
|
||||
if row, _ := s.RecordOperatorActionResult("c1", id, "failed", "late"); row != nil {
|
||||
t.Fatal("a second result overwrote the first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorActions_ExpireAndResetCancel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
old, _ := s.CreateOperatorAction("c1", "offsite_backup_now", "", "operator")
|
||||
if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id = ?`, time.Now().UTC().Add(-25*time.Hour), old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh, _ := s.CreateOperatorAction("c1", "abandon_stop", "", "operator")
|
||||
p, _ := s.PendingOperatorActions("c1")
|
||||
if len(p) != 1 || p[0].ID != fresh {
|
||||
t.Fatalf("a day-old action is still listed: %+v", p)
|
||||
}
|
||||
if r, _ := s.getOperatorAction(old); r == nil || r.Outcome != OperatorActionExpired {
|
||||
t.Fatalf("old row = %+v, want expired", r)
|
||||
}
|
||||
if err := s.PurgeCustomerResetDBState("c1", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
|
||||
t.Fatalf("a RESET left a pending action for the next box: %+v", p)
|
||||
}
|
||||
if r, _ := s.getOperatorAction(fresh); r == nil || r.Outcome != OperatorActionCancelled {
|
||||
t.Fatalf("fresh row after reset = %+v, want cancelled (kept as a record)", r)
|
||||
}
|
||||
}
|
||||
@@ -878,6 +878,10 @@ func (s *Store) migrate() error {
|
||||
if err := s.backfillAPIKeyHashes(); err != nil && s.logger != nil {
|
||||
s.logger.Printf("[ERROR] api_key_hash backfill: %v (unhashed rows still match on their plaintext key)", err)
|
||||
}
|
||||
// R-314/R-279/R-177 (`09` §3 decision 185): the operator's actions for a box (opactions.go).
|
||||
if err := s.migrateOperatorActions(); err != nil {
|
||||
return fmt.Errorf("operator_actions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -865,6 +865,10 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
|
||||
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
||||
"LogBundles": s.hostLogBundleRows(host),
|
||||
"CSRFToken": s.getCSRFToken(r),
|
||||
// Decision 185 (D1): the operator's closed list of actions for this host's controller.
|
||||
"OperatorActions": s.hostOperatorActionRows(host),
|
||||
"OperatorJobNames": store.OperatorJobNames(),
|
||||
"OperatorExtendMaxDay": store.OperatorActionExtendMaxDays,
|
||||
// v0.47.0 stale host removal: the danger-zone card renders ONLY for non-online
|
||||
// hosts — an ONLINE host is never deletable (no override exists).
|
||||
// R-30 slice 2 (D2): an online-by-report host whose box has been unreachable for
|
||||
|
||||
@@ -171,8 +171,14 @@ func TestHandleHostDetail(t *testing.T) {
|
||||
// v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host
|
||||
// danger-zone card must NOT render for it — this pin now doubles as the
|
||||
// "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly).
|
||||
if got := strings.Count(strings.ToLower(body), "<button"); got != 2 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
|
||||
// Decision 185 (2026-10-08): plus the four operator-action buttons — a closed list, none of which
|
||||
// deletes or mutates the host (TestOperatorActions_* pin what they may do). Every OTHER button
|
||||
// remains absent: 2 log requests + 4 operator actions, and the 4 all post to /operator-action.
|
||||
if got := strings.Count(strings.ToLower(body), "<button"); got != 6 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request + 4 operator-action buttons", got)
|
||||
}
|
||||
if got := strings.Count(body, `action="/hosts/demo-felhom-01/operator-action"`); got != 4 {
|
||||
t.Errorf("operator-action forms = %d, want 4", got)
|
||||
}
|
||||
if strings.Count(body, `action="/hosts/demo-felhom-01/request-logs"`) != 2 {
|
||||
t.Error("the request-logs forms are missing — every button must be a log-bundle request")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// Operator actions (R-314/R-279/R-177, `09` §3 decision 185 — D1). The host page's buttons store a
|
||||
// pending row (store/opactions.go) and wake the box's wait channel; the box acts on its next report
|
||||
// reply and answers on the report after that. The list is CLOSED and validated here, before anything
|
||||
// is stored — an unknown action, job or argument is a 400 and no row.
|
||||
|
||||
// opActionsShown is how many rows the host page lists.
|
||||
const opActionsShown = 10
|
||||
|
||||
// operatorActor names who pressed, for the log and the row: the channel and the address. The hub has
|
||||
// one operator password and no user names, so this is the most it can say — never a credential.
|
||||
func operatorActor(r *http.Request) string {
|
||||
channel := "operator CLI (basic auth)"
|
||||
if _, err := r.Cookie(SessionCookieName); err == nil {
|
||||
channel = "operator browser session"
|
||||
}
|
||||
return channel + " from " + bindClientIP(r)
|
||||
}
|
||||
|
||||
// handleOperatorAction — POST /hosts/{id}/operator-action (form: action, arg).
|
||||
func (s *Server) handleOperatorAction(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
if !s.validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil || host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if host.CustomerID == "" {
|
||||
http.Error(w, "This host has no customer — there is no controller to act", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
action := strings.TrimSpace(r.FormValue("action"))
|
||||
arg := strings.TrimSpace(r.FormValue("arg"))
|
||||
if err := store.ValidateOperatorAction(action, arg); err != nil {
|
||||
s.logger.Printf("[INFO] operator action refused for host %s: %v", hostID, err)
|
||||
http.Error(w, "Refused: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
by := operatorActor(r)
|
||||
id, err := s.store.CreateOperatorAction(host.CustomerID, action, arg, by)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] operator action %s for %s: %v", action, host.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] operator action #%d %s%s requested for %s (host %s) by %s — the box acts on its next report",
|
||||
id, action, opArgSuffix(arg), host.CustomerID, hostID, by)
|
||||
// Direction-2: wake the controller's wait channel so the reply carrying the action comes in seconds.
|
||||
s.bumpIntent(host.CustomerID)
|
||||
http.Redirect(w, r, "/hosts/"+hostID+"#operator-actions", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func opArgSuffix(arg string) string {
|
||||
if arg == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + arg
|
||||
}
|
||||
|
||||
// hostOperatorActionRows is the host page's list (newest first). nil for a host with no customer.
|
||||
func (s *Server) hostOperatorActionRows(host *store.Host) []store.OperatorActionRow {
|
||||
if host.CustomerID == "" {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.store.ListOperatorActions(host.CustomerID, opActionsShown)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] operator actions for %s: %v", host.CustomerID, err)
|
||||
return nil
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1). Red test (4) of the design, web half: a host-page POST stores a row and
|
||||
// bumps the box's intent; an unknown action is refused with no row. Plus a render test per branch of
|
||||
// the card's template gate (the seam-built-but-never-wired trap covers templates).
|
||||
|
||||
func postOperatorAction(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/operator-action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Forwarded-For", "10.9.8.7")
|
||||
// The CLI channel's CSRF pass (validateCSRF): Basic auth + the operator header.
|
||||
req.SetBasicAuth("", "pw")
|
||||
req.Header.Set(OperatorCLIHeader, "confirm")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleOperatorAction(rr, req, hostID)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestOperatorAction_PostStoresAndBumpsIntent(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
hub := intent.New()
|
||||
s.SetIntentHub(hub)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := hub.Generation("c1")
|
||||
rr := postOperatorAction(t, s, "h1", url.Values{"action": {"abandon_extend"}, "arg": {"14"}})
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rows, _ := st.ListOperatorActions("c1", 10)
|
||||
if len(rows) != 1 || rows[0].Action != "abandon_extend" || rows[0].Arg != "14" || rows[0].DoneAt != nil {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
if !strings.Contains(rows[0].RequestedBy, "10.9.8.7") {
|
||||
t.Errorf("requested_by = %q, want the operator's address", rows[0].RequestedBy)
|
||||
}
|
||||
if hub.Generation("c1") == before {
|
||||
t.Error("the box's intent was not bumped — its wait channel would not wake")
|
||||
}
|
||||
if p, _ := st.PendingOperatorActions("c1"); len(p) != 1 {
|
||||
t.Fatalf("the next ACK would list %d action(s), want 1", len(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorAction_UnknownRefusedNothingStored(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
hub := intent.New()
|
||||
s.SetIntentHub(hub)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := hub.Generation("c1")
|
||||
for _, f := range []url.Values{
|
||||
{"action": {"delete_offsite"}},
|
||||
{"action": {"run_job"}, "arg": {"offsite-abandon-sweep"}},
|
||||
{"action": {"abandon_extend"}, "arg": {"45"}},
|
||||
} {
|
||||
if rr := postOperatorAction(t, s, "h1", f); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("%v: status = %d, want 400", f, rr.Code)
|
||||
}
|
||||
}
|
||||
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
|
||||
t.Fatalf("a refused press stored %+v", rows)
|
||||
}
|
||||
if hub.Generation("c1") != before {
|
||||
t.Error("a refused press woke the box")
|
||||
}
|
||||
// A host with no customer has no controller to act.
|
||||
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rr := postOperatorAction(t, s, "lonely", url.Values{"action": {"abandon_stop"}}); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("no-customer host: status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func renderHost(t *testing.T, s *Server, hostID string) string {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil), hostID)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
// One render per branch: customer + no rows, customer + rows (pending and closed), no customer.
|
||||
func TestOperatorAction_CardRendersPerBranch(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := renderHost(t, s, "h1")
|
||||
if got := strings.Count(body, `action="/hosts/h1/operator-action"`); got != 4 {
|
||||
t.Errorf("customer host: %d operator-action forms, want 4", got)
|
||||
}
|
||||
for _, want := range []string{`value="offsite_backup_now"`, `value="abandon_stop"`, `value="abandon_extend"`, `value="run_job"`, `<option value="fill-watch">`, `max="30"`, "No operator actions for this box yet."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("customer host, no rows: missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
id, _ := st.CreateOperatorAction("c1", "run_job", "offsite-proof", "operator browser session from 10.0.0.1")
|
||||
_, _ = st.CreateOperatorAction("c1", "abandon_stop", "", "operator browser session from 10.0.0.1")
|
||||
if _, err := st.RecordOperatorActionResult("c1", id, "refused", "the job offsite-proof was not started"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = renderHost(t, s, "h1")
|
||||
for _, want := range []string{"run_job offsite-proof", ">refused<", "the job offsite-proof was not started", ">pending<", "from 10.0.0.1"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("customer host, rows: missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "No operator actions for this box yet.") {
|
||||
t.Error("the empty line rendered beside rows")
|
||||
}
|
||||
|
||||
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = renderHost(t, s, "lonely")
|
||||
if strings.Contains(body, "/operator-action") {
|
||||
t.Error("a host with no customer rendered operator-action buttons")
|
||||
}
|
||||
if !strings.Contains(body, "there is no controller to act") {
|
||||
t.Error("the no-customer branch did not say why there are no buttons")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorAction_NoCSRFNoRow(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/h1/operator-action", strings.NewReader("action=abandon_stop"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleOperatorAction(rr, req, "h1")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rr.Code)
|
||||
}
|
||||
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
|
||||
t.Fatal("a press without CSRF stored a row")
|
||||
}
|
||||
}
|
||||
@@ -537,6 +537,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
// Decision 185 (D1): the operator's actions — suffix route BEFORE the bare /hosts/ catch-all.
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/operator-action"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/operator-action")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleOperatorAction(w, r, hostID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
@@ -283,6 +283,80 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Operator actions (decision 185, R-314/R-279/R-177): a CLOSED list. None deletes data, starts a
|
||||
countdown or shortens one; the box acts on its next report reply (seconds, at most one cycle). -->
|
||||
<section class="card" id="operator-actions">
|
||||
<h2>Operator Actions</h2>
|
||||
{{if .CustomerID}}
|
||||
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
||||
The box acts on its next report reply (usually seconds, at most one report interval) and answers on the
|
||||
report after that. Each press runs once. Recorded in the box's own log and in Events. Unanswered after a day: expired.
|
||||
</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0;">
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="offsite_backup_now">
|
||||
<button type="submit" class="btn btn-sm">Run off-site backup now</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="run_job">
|
||||
<select name="arg">{{range .OperatorJobNames}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
<button type="submit" class="btn btn-sm">Run check now</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="abandon_stop">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Stop deletion countdown</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="abandon_extend">
|
||||
<input type="number" name="arg" min="1" max="{{.OperatorExtendMaxDay}}" value="7" style="width: 4.5em; padding: 0.3em 0.5em;" aria-label="days">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Extend countdown (days from now)</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .OperatorActions}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Action</th>
|
||||
<th>Requested</th>
|
||||
<th>By</th>
|
||||
<th>Outcome</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .OperatorActions}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Action}}{{if .Arg}} {{.Arg}}{{end}}</td>
|
||||
<td>{{timeAgo .RequestedAt}}</td>
|
||||
<td>{{.RequestedBy}}</td>
|
||||
<td>
|
||||
{{if not .DoneAt}}<span class="badge badge-neutral">pending</span>
|
||||
{{else if eq .Outcome "done"}}<span class="badge badge-ok">done</span>
|
||||
{{else if eq .Outcome "failed"}}<span class="badge badge-error">failed</span>
|
||||
{{else}}<span class="badge badge-warn">{{.Outcome}}</span>{{end}}
|
||||
{{if .Message}}<span style="color: var(--text-muted); font-size: 0.85rem;">{{.Message}}</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>No operator actions for this box yet.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>This host has no customer, so there is no controller to act.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Network (v0.85.0): where this box actually is. Addresses come from the agent's
|
||||
addresses[] (agent >= 0.119.0); the WireGuard row pairs the HUB's allocation with
|
||||
whether the box confirms holding it, because an allocation alone cannot tell a live
|
||||
|
||||
@@ -90,6 +90,10 @@ ROOTS = [
|
||||
# contract nothing can check.
|
||||
("hub -> agent (GET /hosts/<id>/escrow/retained)",
|
||||
"hub", "internal/api", "RetainedEscrowResponse", "agent"),
|
||||
# Decision 185 (2026-10-08, R-314/R-279/R-177): the operator's actions, one entry of the report
|
||||
# ACK's operator_actions list. Named type for the same reason as R-311's root above.
|
||||
("hub -> controller (report ACK, `operator_actions` entry)",
|
||||
"hub", "internal/store", "OperatorActionDirective", "controller"),
|
||||
]
|
||||
|
||||
# R-315: a root whose receiver decodes it into ONE NAMED MIRROR TYPE gets the stronger check —
|
||||
@@ -103,6 +107,7 @@ ROOTS = [
|
||||
MIRRORS = {
|
||||
"hub -> agent (GET /hosts/<id>/escrow/retained)": ("internal/hub", "RetainedEscrowResponse"),
|
||||
"hub -> controller (report ACK, `escrow` object)": ("internal/report", "EscrowStatus"),
|
||||
"hub -> controller (report ACK, `operator_actions` entry)": ("internal/report", "OperatorAction"),
|
||||
}
|
||||
|
||||
# R-444 (2026-10-06): a SUBTREE of a name-checked root whose receiver decodes it into one named mirror type gets the
|
||||
|
||||
Reference in New Issue
Block a user