Add Docker action readiness reasons
Some checks failed
Build and Test / Secret Scan (push) Has been cancelled
Build and Test / Frontend & Backend (push) Has been cancelled

Refs #1034
This commit is contained in:
rcourtman 2026-06-12 23:58:24 +01:00
parent 8e760bb023
commit 2a33c3a09a
16 changed files with 241 additions and 37 deletions

View file

@ -239,7 +239,9 @@ Disconnected command-agent state is also API-owned readiness: lifecycle
surfaces may reflect missing backend-advertised capabilities, but must not
reconnect, substitute, or directly address an agent to make a stale container
action executable. Backend resource payloads and plan-action readiness are the
only supported lifecycle signal for that state.
only supported lifecycle signal for that state. When a resource payload carries
typed `actionReadiness`, lifecycle surfaces may display the reason but must not
treat it as reconnect authority or an alternate command grant.
Assistant session rename through `PATCH /api/ai/sessions/{id}` follows that
same browser-safe history boundary. Lifecycle surfaces, MCP adapters, and
agents may display the updated title as human navigation metadata, but they

View file

@ -1013,6 +1013,10 @@ the canonical monitored-system blocked payload.
uses that hook for command-agent connectivity and runtime posture; browser
controls may consume advertised capabilities, but must not replace this
check with direct shell, SSH, provider, or agent-command calls.
Resource payloads may expose the same executor-owned unavailable state as
`actionReadiness[]` entries with stable `name`, `available`, `reasonCode`,
and `reason` fields so browser and agent clients can explain disabled
actions without treating unavailable capabilities as executable.
Action approval decisions are API-owned as a separate non-execution
contract: `POST /api/actions/{id}/decision` may only record an
`approved` or `rejected` decision against a persisted `pending_approval`

View file

@ -255,6 +255,10 @@ metadata-only API responses, and the unified-resource owner supplies the
Namespace, ConfigMap, Secret, and ServiceAccount-specific columns. Kubernetes
Node inventory must also be reachable through a dedicated native tab, not only
the overview stack, while retaining the shared `PlatformSectionTabs` shell.
Feature-owned Docker / Podman action controls may render backend
`actionReadiness` disabled reasons, but the shared primitive layer owns only the
button/table affordance shell; it must not invent action availability, command
agent state, or alternate execution routes.
Kubernetes Service inventory must likewise stay on the shared tab, toolbar,
table, table-alignment, and inline-detail primitives while the unified-resource
owner supplies Service type, virtual IP, published port, and selector columns.

View file

@ -230,7 +230,9 @@ restore support, or a recovery-local execution path.
Disconnected command-agent readiness for those lifecycle actions remains an
API/runtime fail-closed condition; storage/recovery consumers may observe that
an action is unavailable, but must not reinterpret it as recovery degradation
or attempt a recovery-local container command path.
or attempt a recovery-local container command path. Typed resource
`actionReadiness` reasons are operator explanation only in this subsystem, not
backup freshness, restore support, or recovery action authority.
Storage and recovery surfaces may consume Discovery context from the shared
API boundary when it helps explain protected workloads or storage-adjacent
services, including mock-mode config/data/log path examples. That context is

View file

@ -446,6 +446,12 @@ container inventory table.
same readiness boundary and fails closed with `action_execution_unavailable`
before audit creation if the live command path disappears after the resource
response was read.
When filtering an otherwise known lifecycle capability, the same resource
response must preserve a typed `actionReadiness` entry with the action name,
`available=false`, a stable reason code such as
`command_agent_disconnected`, and operator-safe copy. Frontend consumers may
use that field to explain disabled controls, while `capabilities` remains
the executable action set.
TrueNAS app inventory enters the model as native `TrueNASData.App`
metadata on canonical `app-container` resources. The facet is sourced from
the TrueNAS API app inventory (`app.query` plus active workload/stat
@ -1248,8 +1254,8 @@ Docker / Podman container lifecycle controls in
`dockerContainerLifecycleActions.ts` are unified-resource capability consumers:
they may enable start/stop/restart only from backend-advertised resource
capabilities and must use `sourceStatus`, `docker.agentId`, `docker.runtime`,
and `docker.security` only for disabled-state explanation, never as a
feature-local execution bypass. The same controls may render in
`docker.security`, and backend-owned `actionReadiness` only for disabled-state
explanation, never as a feature-local execution bypass. The same controls may render in
`DockerContainersTable.tsx` and the canonical `ResourceDetailDrawer.tsx` header
only for Docker-source app containers backed by Docker or Podman runtime
metadata; other app-container sources such as TrueNAS must not inherit Docker

View file

@ -152,4 +152,23 @@ describe('dockerContainerLifecycleActions', () => {
'Pulse does not currently advertise a fresh restart command capability for this container.',
);
});
it('prefers backend-owned action readiness reasons over generic missing capability copy', () => {
expect(
getDockerContainerLifecycleDisabledReason(
resource({
capabilities: [],
actionReadiness: [
{
name: 'restart',
available: false,
reasonCode: 'command_agent_disconnected',
reason: 'Docker / Podman command agent is not connected.',
},
],
}),
'restart',
),
).toBe('Docker / Podman command agent is not connected.');
});
});

View file

@ -89,6 +89,32 @@ const stateDisabledReason = (
return undefined;
};
const actionReadinessDisabledReason = (
resource: Resource,
action: DockerContainerLifecycleAction,
): string | undefined => {
const readiness = resource.actionReadiness?.find(
(item) => normalizeToken(item.name) === action && item.available === false,
);
if (!readiness) return undefined;
const reason = asTrimmedString(readiness.reason);
if (reason) return reason;
switch (normalizeToken(readiness.reasonCode)) {
case 'command_agent_disconnected':
return 'Docker / Podman command agent is not connected.';
case 'command_agent_unavailable':
return 'Docker / Podman command execution is not available.';
case 'stale_inventory':
return 'Docker / Podman inventory is not fresh enough to run lifecycle actions.';
case 'host_policy_blocked':
return 'Docker / Podman host policy blocks mutating lifecycle actions.';
case 'unsupported_handler':
return 'This container action is not routed through the supported lifecycle executor.';
default:
return undefined;
}
};
export const getDockerContainerLifecycleDisabledReason = (
resource: Resource,
action: DockerContainerLifecycleAction,
@ -117,6 +143,9 @@ export const getDockerContainerLifecycleDisabledReason = (
const stateReason = stateDisabledReason(resource, action);
if (stateReason) return stateReason;
const readinessReason = actionReadinessDisabledReason(resource, action);
if (readinessReason) return readinessReason;
if (!dockerContainerLifecycleCapability(resource, action)) {
return `Pulse does not currently advertise a fresh ${action} command capability for this container.`;
}

View file

@ -312,6 +312,13 @@ export interface ResourceCapability {
params?: ResourceCapabilityParam[];
}
export interface ResourceActionReadiness {
name: string;
available: boolean;
reasonCode?: string;
reason?: string;
}
export interface ResourceSourceStatus {
status?: string;
lastSeen?: string;
@ -1422,6 +1429,7 @@ export interface Resource {
policy?: ResourcePolicy;
aiSafeSummary?: string;
capabilities?: ResourceCapability[];
actionReadiness?: ResourceActionReadiness[];
sourceStatus?: Record<string, ResourceSourceStatus>;
relationships?: ResourceRelationship[];
recentChanges?: ResourceChange[];

View file

@ -27,7 +27,7 @@ type ActionExecutor interface {
// ActionAvailabilityChecker lets an executor contribute live readiness checks
// before Pulse advertises or persists an executable action plan.
type ActionAvailabilityChecker interface {
CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) error
CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness
}
type actionDecisionRequest struct {
@ -121,11 +121,12 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque
req = normalizeActionRequestForAudit(req)
if checker, ok := h.actionExecutor.(ActionAvailabilityChecker); ok {
if err := checker.CheckActionAvailable(r.Context(), req, *resource); err != nil {
if readiness := checker.CheckActionAvailable(r.Context(), req, *resource); readiness.Name != "" && !readiness.Available {
writeJSONErrorWithDetails(w, http.StatusConflict, "action_execution_unavailable", "Action execution is unavailable", map[string]string{
"resourceId": req.ResourceID,
"capabilityName": req.CapabilityName,
"reason": sanitizeErrorForClient(err, "action execution is unavailable"),
"reasonCode": readiness.ReasonCode,
"reason": firstNonEmpty(readiness.Reason, "action execution is unavailable"),
})
return
}

View file

@ -15150,6 +15150,43 @@ func TestContract_PlanActionDeclaresExecutionUnavailable(t *testing.T) {
}
}
func TestContract_ResourceActionReadinessPayloadShape(t *testing.T) {
payload, err := json.Marshal(unifiedresources.Resource{
ID: "app-container:docker:web",
Type: unifiedresources.ResourceTypeAppContainer,
Name: "web",
Status: unifiedresources.StatusOnline,
LastSeen: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker},
ActionReadiness: []unifiedresources.ResourceActionReadiness{
{
Name: "restart",
Available: false,
ReasonCode: "command_agent_disconnected",
Reason: "Docker / Podman command agent is not connected.",
},
},
})
if err != nil {
t.Fatalf("marshal resource: %v", err)
}
body := string(payload)
for _, want := range []string{
`"actionReadiness":[`,
`"name":"restart"`,
`"available":false`,
`"reasonCode":"command_agent_disconnected"`,
`"reason":"Docker / Podman command agent is not connected."`,
} {
if !strings.Contains(body, want) {
t.Fatalf("resource action readiness payload missing %s: %s", want, body)
}
}
if strings.Contains(body, "InternalHandler") {
t.Fatalf("resource payload leaked internal action handler: %s", body)
}
}
// TestContract_AgentCapabilitiesManifestIsPublic pins the auth
// contract for the discovery surface: the manifest must be in the
// router's publicPaths list so it serves without a token. The

View file

@ -57,7 +57,7 @@ func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record
return nil, fmt.Errorf("docker container resource %q is not backed by a reporting Pulse agent", record.Request.ResourceID)
}
if !e.agents.IsAgentConnected(agentID) {
return nil, fmt.Errorf("docker container command agent %q is not connected", agentID)
return nil, fmt.Errorf("docker container command agent is not connected")
}
command := fmt.Sprintf("%s %s %s", runtime, operation, shellQuote(containerRef))
@ -91,23 +91,24 @@ func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record
return execution, nil
}
func (e dockerContainerActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) error {
func (e dockerContainerActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness {
operation := strings.TrimSpace(req.CapabilityName)
capability, ok := findDockerLifecycleCapability(resource.Capabilities, operation)
if !ok || capability.InternalHandler != dockerContainerLifecycleHandler {
return nil
return unified.ResourceActionReadiness{}
}
readiness := unified.ResourceActionReadiness{Name: operation, Available: true}
if e.agents == nil {
return fmt.Errorf("docker container command agent manager is not available")
return unavailableDockerActionReadiness(operation, "command_agent_unavailable", "Docker / Podman command execution is not available.")
}
if _, err := e.executableDockerContainerResource(ctx, resource, operation); err != nil {
return err
return unavailableDockerActionReadiness(operation, dockerActionUnavailableReasonCode(err), dockerActionUnavailableReason(err))
}
agentID := strings.TrimSpace(resource.Docker.AgentID)
if !e.agents.IsAgentConnected(agentID) {
return fmt.Errorf("docker container command agent %q is not connected", agentID)
return unavailableDockerActionReadiness(operation, "command_agent_disconnected", "Docker / Podman command agent is not connected.")
}
return nil
return readiness
}
func (e dockerContainerActionExecutor) currentDockerContainerResource(ctx context.Context, resourceID, operation string) (unified.Resource, error) {
@ -129,7 +130,7 @@ func (e dockerContainerActionExecutor) executableDockerContainerResource(_ conte
if resource.Type != unified.ResourceTypeAppContainer || resource.Docker == nil {
return unified.Resource{}, fmt.Errorf("resource %q is not a Docker or Podman container", resource.ID)
}
if status := resource.SourceStatus[unified.SourceDocker]; strings.EqualFold(strings.TrimSpace(status.Status), "stale") || strings.EqualFold(strings.TrimSpace(status.Status), "offline") {
if status := resource.SourceStatus[unified.SourceDocker]; strings.EqualFold(strings.TrimSpace(status.Status), "stale") || strings.EqualFold(strings.TrimSpace(status.Status), "offline") || strings.EqualFold(strings.TrimSpace(status.Status), "missing") {
return unified.Resource{}, fmt.Errorf("docker inventory for resource %q is %s", resource.ID, status.Status)
}
if resource.Docker.Security != nil && resource.Docker.Security.MutatingCommandsBlocked {
@ -147,6 +148,53 @@ func (e dockerContainerActionExecutor) executableDockerContainerResource(_ conte
return resource, nil
}
func unavailableDockerActionReadiness(operation, reasonCode, reason string) unified.ResourceActionReadiness {
return unified.ResourceActionReadiness{
Name: strings.TrimSpace(operation),
Available: false,
ReasonCode: strings.TrimSpace(reasonCode),
Reason: strings.TrimSpace(reason),
}
}
func dockerActionUnavailableReasonCode(err error) string {
if err == nil {
return "unavailable"
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "not a docker or podman container"):
return "unsupported_resource"
case strings.Contains(message, "inventory") && (strings.Contains(message, "stale") || strings.Contains(message, "offline") || strings.Contains(message, "missing")):
return "stale_inventory"
case strings.Contains(message, "blocked by host policy"):
return "host_policy_blocked"
case strings.Contains(message, "does not currently advertise"):
return "capability_unavailable"
case strings.Contains(message, "unsupported handler"):
return "unsupported_handler"
default:
return "unavailable"
}
}
func dockerActionUnavailableReason(err error) string {
switch dockerActionUnavailableReasonCode(err) {
case "unsupported_resource":
return "Resource is not a Docker or Podman container."
case "stale_inventory":
return "Docker / Podman inventory is not fresh enough to run lifecycle actions."
case "host_policy_blocked":
return "Docker / Podman host policy blocks mutating lifecycle actions."
case "capability_unavailable":
return "Pulse does not currently advertise a fresh command capability for this container."
case "unsupported_handler":
return "This container action is not routed through the supported lifecycle executor."
default:
return "Docker / Podman lifecycle action is not currently available."
}
}
func findDockerLifecycleCapability(capabilities []unified.ResourceCapability, operation string) (unified.ResourceCapability, bool) {
operation = strings.TrimSpace(operation)
for _, capability := range capabilities {

View file

@ -48,6 +48,15 @@ func dockerActionCapabilityNames(capabilities []unified.ResourceCapability) []st
return names
}
func dockerActionReadinessByName(readinesses []unified.ResourceActionReadiness, name string) (unified.ResourceActionReadiness, bool) {
for _, readiness := range readinesses {
if readiness.Name == name {
return readiness, true
}
}
return unified.ResourceActionReadiness{}, false
}
func TestDockerContainerActionExecutorDispatchesPodmanRestartAndVerification(t *testing.T) {
now := time.Now().UTC()
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
@ -116,15 +125,15 @@ func TestDockerContainerActionExecutorAvailabilityRequiresConnectedAgent(t *test
agents := &fakeDockerActionAgentCommander{connected: map[string]bool{"agent-1": false}}
executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor)
err := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{
readiness := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{
RequestID: "req-availability",
ResourceID: "app-container:api",
CapabilityName: "restart",
Reason: "operator requested restart",
RequestedBy: "operator",
}, resource)
if err == nil || !strings.Contains(err.Error(), `docker container command agent "agent-1" is not connected`) {
t.Fatalf("CheckActionAvailable err = %v, want disconnected agent", err)
if readiness.Available || readiness.ReasonCode != "command_agent_disconnected" || readiness.Reason != "Docker / Podman command agent is not connected." {
t.Fatalf("CheckActionAvailable readiness = %#v, want disconnected agent", readiness)
}
if len(agents.calls) != 0 {
t.Fatalf("agent calls = %#v, want none", agents.calls)
@ -158,7 +167,8 @@ func TestHandlePlanActionRejectsDisconnectedDockerContainerAgent(t *testing.T) {
t.Fatalf("plan status = %d, want %d, body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"error":"action_execution_unavailable"`) ||
!strings.Contains(rec.Body.String(), `"reason":"action execution is unavailable"`) {
!strings.Contains(rec.Body.String(), `"reason":"Docker / Podman command agent is not connected."`) ||
!strings.Contains(rec.Body.String(), `"reasonCode":"command_agent_disconnected"`) {
t.Fatalf("unexpected response body: %s", rec.Body.String())
}
store, err := h.getStore("default")
@ -203,6 +213,10 @@ func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testi
if got := dockerActionCapabilityNames(list.Data[0].Capabilities); len(got) != 0 {
t.Fatalf("list capabilities = %#v, want none", got)
}
readiness, ok := dockerActionReadinessByName(list.Data[0].ActionReadiness, "restart")
if !ok || readiness.Available || readiness.ReasonCode != "command_agent_disconnected" {
t.Fatalf("list action readiness = %#v, ok=%v; want disconnected restart", list.Data[0].ActionReadiness, ok)
}
detailRec := httptest.NewRecorder()
detailReq := httptest.NewRequest(http.MethodGet, "/api/resources/app-container:api", nil)
@ -217,6 +231,10 @@ func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testi
if got := dockerActionCapabilityNames(detail.Capabilities); len(got) != 0 {
t.Fatalf("detail capabilities = %#v, want none", got)
}
readiness, ok = dockerActionReadinessByName(detail.ActionReadiness, "restart")
if !ok || readiness.Available || readiness.Reason != "Docker / Podman command agent is not connected." {
t.Fatalf("detail action readiness = %#v, ok=%v; want disconnected restart", detail.ActionReadiness, ok)
}
}
func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T) {

View file

@ -319,6 +319,7 @@ func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resource
continue
}
filtered := make([]unified.ResourceCapability, 0, len(resources[i].Capabilities))
readinesses := make([]unified.ResourceActionReadiness, 0, len(resources[i].Capabilities))
for _, capability := range resources[i].Capabilities {
req := unified.ActionRequest{
RequestID: "resource-capability-availability",
@ -328,7 +329,9 @@ func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resource
Reason: "Projecting currently executable resource capabilities.",
RequestedBy: "system:resource-api",
}
if err := checker.CheckActionAvailable(ctx, req, resources[i]); err != nil {
readiness := checker.CheckActionAvailable(ctx, req, resources[i])
if readiness.Name != "" && !readiness.Available {
readinesses = append(readinesses, readiness)
continue
}
filtered = append(filtered, capability)
@ -338,6 +341,9 @@ func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resource
} else {
resources[i].Capabilities = filtered
}
if len(readinesses) > 0 {
resources[i].ActionReadiness = readinesses
}
}
}

View file

@ -40,3 +40,12 @@ type ResourceCapability struct {
InternalHandler string `json:"-"` // DO NOT expose execution plumbing to public surfaces
Params []CapabilityParam `json:"params,omitempty"`
}
// ResourceActionReadiness carries backend-owned current executability state
// for a capability that may be known but unavailable right now.
type ResourceActionReadiness struct {
Name string `json:"name"`
Available bool `json:"available"`
ReasonCode string `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
}

View file

@ -572,9 +572,18 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) {
"func RedactAuditRecord(record ActionAuditRecord) ActionAuditRecord",
"func redactActionExecutionResult(result *ExecutionResult) *ExecutionResult",
},
filepath.Join(".", "capabilities.go"): {
"type ResourceCapability struct",
"type ResourceActionReadiness struct",
"ReasonCode string `json:\"reasonCode,omitempty\"`",
},
filepath.Join(".", "types.go"): {
"ActionReadiness []ResourceActionReadiness `json:\"actionReadiness,omitempty\"`",
},
filepath.Join("..", "api", "actions.go"): {
"type ActionExecutor interface",
"type ActionAvailabilityChecker interface",
"CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness",
"func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request)",
"func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error",
"func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord",
@ -591,6 +600,7 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) {
"func (h *ResourceHandlers) SetActionExecutor(executor ActionExecutor)",
"func (h *ResourceHandlers) SetActionCompletedPublisher(",
"func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resources []unified.Resource)",
"resources[i].ActionReadiness = readinesses",
},
filepath.Join("..", "api", "agent_events.go"): {
"func (b *AgentEventBroadcaster) PublishActionCompletedRecord(record unifiedresources.ActionAuditRecord)",

View file

@ -47,23 +47,24 @@ type Resource struct {
ChildCount int `json:"childCount,omitempty"`
parentBySource map[DataSource]string
Tags []string `json:"tags,omitempty"`
CustomURL string `json:"customUrl,omitempty"`
Capabilities []ResourceCapability `json:"capabilities,omitempty"`
Relationships []ResourceRelationship `json:"relationships,omitempty"`
RecentChanges []ResourceChange `json:"recentChanges,omitempty"`
FacetCounts ResourceFacetCounts `json:"facetCounts,omitempty"`
Incidents []ResourceIncident `json:"incidents,omitempty"`
IncidentCount int `json:"incidentCount,omitempty"`
IncidentCode string `json:"incidentCode,omitempty"`
IncidentSeverity storagehealth.RiskLevel `json:"incidentSeverity,omitempty"`
IncidentSummary string `json:"incidentSummary,omitempty"`
IncidentCategory string `json:"incidentCategory,omitempty"`
IncidentLabel string `json:"incidentLabel,omitempty"`
IncidentPriority int `json:"incidentPriority,omitempty"`
IncidentImpactSummary string `json:"incidentImpactSummary,omitempty"`
IncidentUrgency string `json:"incidentUrgency,omitempty"`
IncidentAction string `json:"incidentAction,omitempty"`
Tags []string `json:"tags,omitempty"`
CustomURL string `json:"customUrl,omitempty"`
Capabilities []ResourceCapability `json:"capabilities,omitempty"`
ActionReadiness []ResourceActionReadiness `json:"actionReadiness,omitempty"`
Relationships []ResourceRelationship `json:"relationships,omitempty"`
RecentChanges []ResourceChange `json:"recentChanges,omitempty"`
FacetCounts ResourceFacetCounts `json:"facetCounts,omitempty"`
Incidents []ResourceIncident `json:"incidents,omitempty"`
IncidentCount int `json:"incidentCount,omitempty"`
IncidentCode string `json:"incidentCode,omitempty"`
IncidentSeverity storagehealth.RiskLevel `json:"incidentSeverity,omitempty"`
IncidentSummary string `json:"incidentSummary,omitempty"`
IncidentCategory string `json:"incidentCategory,omitempty"`
IncidentLabel string `json:"incidentLabel,omitempty"`
IncidentPriority int `json:"incidentPriority,omitempty"`
IncidentImpactSummary string `json:"incidentImpactSummary,omitempty"`
IncidentUrgency string `json:"incidentUrgency,omitempty"`
IncidentAction string `json:"incidentAction,omitempty"`
// Source-specific payloads
Proxmox *ProxmoxData `json:"proxmox,omitempty"`