87af859fc3
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
58 lines
2.1 KiB
Go
58 lines
2.1 KiB
Go
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
|
|
}
|