mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(alerts): add recurring scoped maintenance
This commit is contained in:
@@ -3294,7 +3294,8 @@ restarts because the underlying state lives in the durable
|
||||
The `/api/resources/{id}/operator-state` GET / PUT / DELETE handlers in
|
||||
`internal/api/resources_operator_state.go` are the canonical operator
|
||||
surface for setting per-resource intent (intentionally offline, never
|
||||
auto-remediate, maintenance window, criticality hint). The route lives
|
||||
auto-remediate, mutually exclusive one-shot or timezone-aware recurring
|
||||
maintenance with explicit resource/descendant scope, criticality hint). The route lives
|
||||
on the same monitoring router (`router_routes_monitoring.go`) as the
|
||||
rest of `/api/resources/{id}/...`; method-keyed scope dispatch means GET
|
||||
runs under `monitoring:read` while PUT and DELETE require
|
||||
@@ -3302,7 +3303,7 @@ runs under `monitoring:read` while PUT and DELETE require
|
||||
against the resource. The agent runtime must surface the same
|
||||
operator-set state across restarts — persistence is in the
|
||||
`resource_operator_state` SQLite table managed by the unified-resources
|
||||
store from slice 29 — so a maintenance window or never-auto-remediate
|
||||
store from slice 29 — so either maintenance schedule form or never-auto-remediate
|
||||
flag set before a process restart is honored after the agent reloads.
|
||||
|
||||
Patrol-finding to unified-finding mirroring in `internal/api/router.go`
|
||||
|
||||
@@ -5164,7 +5164,12 @@ new-finding path auto-dismisses with reason `expected_behavior`,
|
||||
attributes the suppression on the lifecycle timeline
|
||||
(`operator_state_cause: maintenance_window`, with
|
||||
`maintenance_end_at` metadata), and persists the finding for audit
|
||||
history. The action broker consults the same `resource_operator_state` table
|
||||
history. The active boundary may come from a one-shot or recurring schedule;
|
||||
both are evaluated by the canonical operator-state model. Descendant-scope
|
||||
inheritance is alert-intent behavior and does not weaken exact-resource
|
||||
action-remediation locks or invent inherited action authority.
|
||||
|
||||
The action broker consults the same `resource_operator_state` table
|
||||
on every dispatch — both the agent-command path
|
||||
(`executeCommandWithAudit`) and the native provider path
|
||||
(`executeNativeActionWithAudit`) in
|
||||
|
||||
@@ -2028,7 +2028,13 @@ durable pending state and its transient monotonic baseline before a later
|
||||
outage can start.
|
||||
|
||||
Operator maintenance and intentionally-offline state are read only through the
|
||||
canonical unified-resource identity. Backup-aware offline deferral consumes
|
||||
canonical unified-resource identity. One-shot and recurring maintenance use
|
||||
the same effective occurrence boundary. Exact-resource maintenance always
|
||||
applies; ancestor maintenance applies only when its persisted scope is
|
||||
`resource_and_descendants`. Overlapping exact and inherited windows remain
|
||||
suppressed until the latest active end, and every operator-state mutation
|
||||
reconciles the active-alert set immediately so a host window cannot leave child
|
||||
alerts visible until another detector write. Backup-aware offline deferral consumes
|
||||
fresh, matching, active task evidence, applies the configured post-backup grace,
|
||||
and always terminates at its hard cap. Missing, stale, future-skewed,
|
||||
finished, or mismatched backup evidence cannot suppress an outage. This policy
|
||||
|
||||
@@ -5193,9 +5193,12 @@ mirrors the canonical Go shape from
|
||||
`getResourceOperatorState`, `setResourceOperatorState`, and
|
||||
`clearResourceOperatorState` against the
|
||||
`/api/resources/{id}/operator-state` endpoint. The GET path
|
||||
normalizes the server's `404 operator_state_not_set` response into
|
||||
`null` so callers see "no state recorded" as a clean default rather
|
||||
than a thrown error; non-404 errors propagate. The PUT path
|
||||
uses `view=lookup` so an unset record is represented by a successful
|
||||
`{ "configured": false }` envelope rather than a routine failed browser
|
||||
request. Persisted records return `{ "configured": true, "state": {...} }`.
|
||||
The client continues to normalize an older server's `404
|
||||
operator_state_not_set` response into `null` during rolling updates;
|
||||
non-404 errors propagate. The PUT path
|
||||
percent-encodes the canonical resource id segment so colon-bearing
|
||||
ids round-trip safely through URL routing.
|
||||
Operator-state reads must keep `autoRemediationPolicy.capabilityNames`
|
||||
@@ -5204,6 +5207,13 @@ capability list before JSON serialization, while the TS compatibility type and
|
||||
rendering path continue to tolerate `null` from pre-fix or cached payloads.
|
||||
Saving only `intentionallyOffline`, `neverAutoRemediate`, priority, or note
|
||||
must therefore never produce a drawer-breaking operator-state response.
|
||||
The same read/write shape carries either the legacy one-shot start/end pair or
|
||||
`maintenanceRecurrence` (`timezone`, canonical weekdays, start/end minutes),
|
||||
never both, plus `maintenanceScope` (`resource` by default or
|
||||
`resource_and_descendants`). The resource editor exposes both schedule forms,
|
||||
IANA timezone, overnight recurrence, reason, and explicit descendant scope;
|
||||
unrelated override saves and alert-card monitoring actions round-trip all
|
||||
maintenance fields instead of clobbering them.
|
||||
|
||||
The router wires the operator-state adapter into the findings runtime
|
||||
at startup: `internal/api/router.go` calls
|
||||
@@ -5224,7 +5234,8 @@ and without growing per-finding lookups as new signals land.
|
||||
|
||||
`/api/resources/{id}/operator-state` is the canonical surface for
|
||||
operator-set per-resource intent (intentionally offline, never
|
||||
auto-remediate, maintenance window, criticality hint). GET requires
|
||||
auto-remediate, one-shot or recurring maintenance window with explicit scope,
|
||||
criticality hint). GET requires
|
||||
`monitoring:read` and returns `404` with `{ "error":
|
||||
"operator_state_not_set" }` when no entry exists; PUT and DELETE
|
||||
require `monitoring:write` because they modulate Patrol's behavior on
|
||||
@@ -5242,6 +5253,10 @@ message. DELETE is idempotent (`204` whether or not an entry was
|
||||
present). The handler dispatches off `r.Method` rather than mounting
|
||||
three sibling routes so the URL surface stays a single resource path
|
||||
matching the rest of `/api/resources/{id}/...`.
|
||||
Interactive clients may request `GET .../operator-state?view=lookup`; this
|
||||
preserves the configured-versus-unset distinction in a 200 response envelope
|
||||
and prevents a normal empty state from appearing as a failed browser request.
|
||||
The default GET behavior and stable agent error code remain unchanged.
|
||||
|
||||
The action governance loop at `/api/actions/plan`,
|
||||
`/api/actions/{id}/decision`, and `/api/actions/{id}/execute` is
|
||||
|
||||
@@ -182,7 +182,6 @@ metrics target. Zero or multiple matches must leave the PBS resource unchanged
|
||||
rather than guessing; this presentation correlation must not mutate either
|
||||
canonical input or create a second mobile disclosure interaction.
|
||||
|
||||
|
||||
Presentation helpers that mirror a server-side classification must name the
|
||||
predicate they mirror and expose it as a single exported function rather than
|
||||
inlining the boundary at each call site. `isPhysicalDiskWearoutReported` mirrors
|
||||
@@ -292,67 +291,67 @@ overflow.
|
||||
71. `frontend-modern/src/utils/systemLogsPresentation.ts`
|
||||
72. `frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx`
|
||||
73. `frontend-modern/src/components/Settings/ResourcePicker.tsx`
|
||||
75. `frontend-modern/src/utils/reportableResourceTypes.ts`
|
||||
76. `frontend-modern/src/utils/reportingResourceTypes.ts`
|
||||
77. `frontend-modern/src/utils/workloadEmptyStatePresentation.ts`
|
||||
78. `frontend-modern/src/utils/workloadGuestPresentation.ts`
|
||||
79. `frontend-modern/src/utils/emptyStatePresentation.ts`
|
||||
80. `frontend-modern/src/utils/semanticTonePresentation.ts`
|
||||
81. `frontend-modern/src/components/Toast/Toast.tsx`
|
||||
82. `frontend-modern/src/utils/toast.ts`
|
||||
83. `frontend-modern/src/utils/semanticTonePresentation.ts`
|
||||
84. `frontend-modern/src/utils/emptyStatePresentation.ts`
|
||||
85. `frontend-modern/src/utils/typeColumnPresentation.ts`
|
||||
86. `frontend-modern/src/components/Settings/NetworkBoundarySettingsSection.tsx`
|
||||
87. `frontend-modern/src/components/Settings/networkSettingsModel.ts`
|
||||
88. `frontend-modern/src/components/Settings/useDiscoverySettingsState.ts`
|
||||
89. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
|
||||
90. `frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx`
|
||||
91. `frontend-modern/src/components/Settings/availabilitySettingsModel.ts`
|
||||
92. `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`
|
||||
93. `frontend-modern/src/components/Settings/settingsPanelRegistryLoaders.ts`
|
||||
94. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
|
||||
95. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`
|
||||
96. `frontend-modern/src/components/Settings/settingsNavVisibility.ts`
|
||||
97. `frontend-modern/src/components/Settings/settingsRouting.ts`
|
||||
98. `frontend-modern/src/components/Settings/settingsTabSaveBehavior.ts`
|
||||
99. `frontend-modern/src/components/Settings/settingsTypes.ts`
|
||||
100. `frontend-modern/src/components/Settings/useSettingsNavigation.ts`
|
||||
101. `frontend-modern/src/components/Settings/useSettingsPanelRegistry.tsx`
|
||||
102. `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx`
|
||||
103. `frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx`
|
||||
104. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
|
||||
105. `frontend-modern/src/utils/environmentLockPresentation.ts`
|
||||
106. `frontend-modern/src/utils/docsLinks.ts`
|
||||
107. `tests/integration/tests/20-local-doc-links.spec.ts`
|
||||
108. `frontend-modern/src/index.css`
|
||||
109. `frontend-modern/src/components/shared/summaryInteractionA11y.ts`
|
||||
110. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`
|
||||
111. `frontend-modern/src/hooks/createNonSuspendingQuery.ts`
|
||||
74. `frontend-modern/src/utils/reportableResourceTypes.ts`
|
||||
75. `frontend-modern/src/utils/reportingResourceTypes.ts`
|
||||
76. `frontend-modern/src/utils/workloadEmptyStatePresentation.ts`
|
||||
77. `frontend-modern/src/utils/workloadGuestPresentation.ts`
|
||||
78. `frontend-modern/src/utils/emptyStatePresentation.ts`
|
||||
79. `frontend-modern/src/utils/semanticTonePresentation.ts`
|
||||
80. `frontend-modern/src/components/Toast/Toast.tsx`
|
||||
81. `frontend-modern/src/utils/toast.ts`
|
||||
82. `frontend-modern/src/utils/semanticTonePresentation.ts`
|
||||
83. `frontend-modern/src/utils/emptyStatePresentation.ts`
|
||||
84. `frontend-modern/src/utils/typeColumnPresentation.ts`
|
||||
85. `frontend-modern/src/components/Settings/NetworkBoundarySettingsSection.tsx`
|
||||
86. `frontend-modern/src/components/Settings/networkSettingsModel.ts`
|
||||
87. `frontend-modern/src/components/Settings/useDiscoverySettingsState.ts`
|
||||
88. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
|
||||
89. `frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx`
|
||||
90. `frontend-modern/src/components/Settings/availabilitySettingsModel.ts`
|
||||
91. `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`
|
||||
92. `frontend-modern/src/components/Settings/settingsPanelRegistryLoaders.ts`
|
||||
93. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
|
||||
94. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`
|
||||
95. `frontend-modern/src/components/Settings/settingsNavVisibility.ts`
|
||||
96. `frontend-modern/src/components/Settings/settingsRouting.ts`
|
||||
97. `frontend-modern/src/components/Settings/settingsTabSaveBehavior.ts`
|
||||
98. `frontend-modern/src/components/Settings/settingsTypes.ts`
|
||||
99. `frontend-modern/src/components/Settings/useSettingsNavigation.ts`
|
||||
100. `frontend-modern/src/components/Settings/useSettingsPanelRegistry.tsx`
|
||||
101. `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx`
|
||||
102. `frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx`
|
||||
103. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
|
||||
104. `frontend-modern/src/utils/environmentLockPresentation.ts`
|
||||
105. `frontend-modern/src/utils/docsLinks.ts`
|
||||
106. `tests/integration/tests/20-local-doc-links.spec.ts`
|
||||
107. `frontend-modern/src/index.css`
|
||||
108. `frontend-modern/src/components/shared/summaryInteractionA11y.ts`
|
||||
109. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`
|
||||
110. `frontend-modern/src/hooks/createNonSuspendingQuery.ts`
|
||||
111a. `frontend-modern/src/utils/storageSummaryCache.ts`
|
||||
112. `frontend-modern/src/components/shared/TableCardHeader.tsx`
|
||||
113. `frontend-modern/src/components/shared/UpgradeLink.tsx`
|
||||
114. `frontend-modern/src/components/shared/useUpgradeNavigation.ts`
|
||||
115. `frontend-modern/src/utils/upgradeNavigation.ts`
|
||||
116. `frontend-modern/src/components/DemoBanner.tsx`
|
||||
111. `frontend-modern/src/components/shared/TableCardHeader.tsx`
|
||||
112. `frontend-modern/src/components/shared/UpgradeLink.tsx`
|
||||
113. `frontend-modern/src/components/shared/useUpgradeNavigation.ts`
|
||||
114. `frontend-modern/src/utils/upgradeNavigation.ts`
|
||||
115. `frontend-modern/src/components/DemoBanner.tsx`
|
||||
116a. `frontend-modern/src/components/CommercialMigrationBanner.tsx`
|
||||
116b. `frontend-modern/src/components/GitHubStarBanner.tsx`
|
||||
117. `frontend-modern/src/components/Login.tsx`
|
||||
118. `frontend-modern/src/stores/sessionCapabilities.ts`
|
||||
119. `frontend-modern/src/stores/sessionPresentationPolicy.ts`
|
||||
120. `frontend-modern/src/stores/licenseCommercial.ts`
|
||||
121. `frontend-modern/src/useAppRuntimeState.ts`
|
||||
122. `frontend-modern/src/routing/routePreload.ts`
|
||||
123. `frontend-modern/src/stores/aiChat.ts`
|
||||
124. `frontend-modern/scripts/header-audit.mjs`
|
||||
125. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx`
|
||||
126. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
|
||||
127. `frontend-modern/scripts/canonical-platform-audit.mjs`
|
||||
128. `frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs`
|
||||
129. `frontend-modern/scripts/shared-template-audit.mjs`
|
||||
130. `frontend-modern/scripts/shared-template-registry.json`
|
||||
131. `frontend-modern/src/features/platformPage/sharedPlatformPage.tsx`
|
||||
131a. `frontend-modern/src/features/platformPage/platformSearchSuggestions.ts`
|
||||
116. `frontend-modern/src/components/Login.tsx`
|
||||
117. `frontend-modern/src/stores/sessionCapabilities.ts`
|
||||
118. `frontend-modern/src/stores/sessionPresentationPolicy.ts`
|
||||
119. `frontend-modern/src/stores/licenseCommercial.ts`
|
||||
120. `frontend-modern/src/useAppRuntimeState.ts`
|
||||
121. `frontend-modern/src/routing/routePreload.ts`
|
||||
122. `frontend-modern/src/stores/aiChat.ts`
|
||||
123. `frontend-modern/scripts/header-audit.mjs`
|
||||
124. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx`
|
||||
125. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
|
||||
126. `frontend-modern/scripts/canonical-platform-audit.mjs`
|
||||
127. `frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs`
|
||||
128. `frontend-modern/scripts/shared-template-audit.mjs`
|
||||
129. `frontend-modern/scripts/shared-template-registry.json`
|
||||
130. `frontend-modern/src/features/platformPage/sharedPlatformPage.tsx`
|
||||
131a. `frontend-modern/src/features/platformPage/platformSearchSuggestions.ts`
|
||||
131b. `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
|
||||
131c. `frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx`
|
||||
131d. `frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx`
|
||||
@@ -361,23 +360,23 @@ overflow.
|
||||
131g. `frontend-modern/src/components/shared/FormSelect.tsx`
|
||||
131h. `frontend-modern/src/components/shared/FormTextarea.tsx`
|
||||
131i. `frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx`
|
||||
132. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
|
||||
133. `frontend-modern/src/utils/platformSupportManifest.ts`
|
||||
134. `frontend-modern/src/utils/sourcePlatformOptions.ts`
|
||||
135. `frontend-modern/src/utils/sourcePlatforms.ts`
|
||||
136. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts`
|
||||
137. `frontend-modern/src/components/shared/Button.tsx`
|
||||
138. `frontend-modern/src/components/shared/buttonModel.ts`
|
||||
139. `frontend-modern/src/components/shared/Button.test.tsx`
|
||||
131. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
|
||||
132. `frontend-modern/src/utils/platformSupportManifest.ts`
|
||||
133. `frontend-modern/src/utils/sourcePlatformOptions.ts`
|
||||
134. `frontend-modern/src/utils/sourcePlatforms.ts`
|
||||
135. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts`
|
||||
136. `frontend-modern/src/components/shared/Button.tsx`
|
||||
137. `frontend-modern/src/components/shared/buttonModel.ts`
|
||||
138. `frontend-modern/src/components/shared/Button.test.tsx`
|
||||
139a. `frontend-modern/src/components/shared/InlineNotice.tsx`
|
||||
139b. `frontend-modern/src/components/shared/InlineNotice.test.tsx`
|
||||
139c. `frontend-modern/src/components/shared/ExternalTextLink.tsx`
|
||||
139d. `frontend-modern/src/components/shared/ExternalTextLink.test.tsx`
|
||||
140. `frontend-modern/src/components/shared/CopyableCodeRow.tsx`
|
||||
141. `frontend-modern/src/components/shared/DetailSectionTable.tsx`
|
||||
142. `frontend-modern/src/components/shared/detailSectionModel.ts`
|
||||
143. `frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts`
|
||||
144. `frontend-modern/src/i18n/__tests__/i18n.test.ts`
|
||||
139. `frontend-modern/src/components/shared/CopyableCodeRow.tsx`
|
||||
140. `frontend-modern/src/components/shared/DetailSectionTable.tsx`
|
||||
141. `frontend-modern/src/components/shared/detailSectionModel.ts`
|
||||
142. `frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts`
|
||||
143. `frontend-modern/src/i18n/__tests__/i18n.test.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -720,8 +719,8 @@ scan-friendly without hiding any container from drilldown.
|
||||
|
||||
1. `frontend-modern/src/components/CommercialMigrationBanner.tsx` shared with `cloud-paid`: the global commercial migration notice is both a cloud-paid entitlement recovery surface and a shared app-shell notice primitive consumer.
|
||||
2. `frontend-modern/src/components/Infrastructure/useTableWindowing.ts` shared with `performance-and-scalability`: the shared bounded table-window controller is both a canonical frontend rendering primitive and a fleet-scale scrolling hot-path boundary.
|
||||
2. `frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx` shared with `ai-runtime`, `api-contracts`: the External agents settings panel is the optional settings-shell projection of Pulse MCP onboarding, the AI runtime connected-agent onboarding surface, and a presentation consumer of the shared agent capabilities frontend client.
|
||||
3. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
|
||||
3. `frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx` shared with `ai-runtime`, `api-contracts`: the External agents settings panel is the optional settings-shell projection of Pulse MCP onboarding, the AI runtime connected-agent onboarding surface, and a presentation consumer of the shared agent capabilities frontend client.
|
||||
4. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
|
||||
The panel may own shell placement and local action layout, but
|
||||
token-specific Docker / Podman copy must come from
|
||||
`frontend-modern/src/utils/apiTokenPresentation.ts` rather than page-local
|
||||
@@ -811,9 +810,9 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
|
||||
the Agent integrations panel are settings-shell chrome only: they may route
|
||||
to the API Access token creation section, but token preset semantics and
|
||||
required-scope derivation remain owned by the API/security boundary.
|
||||
4. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `security-privacy`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary.
|
||||
5. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `security-privacy`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary.
|
||||
6. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `security-privacy`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary.
|
||||
5. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `security-privacy`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary.
|
||||
6. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `security-privacy`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary.
|
||||
7. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `security-privacy`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary.
|
||||
The panel owns compact settings-shell framing for outbound usage telemetry, but
|
||||
its vocabulary must stay aligned with `security-privacy`: coarse deployment
|
||||
and lifecycle buckets, aggregate resource and outcome counts, coarse feature
|
||||
@@ -822,8 +821,8 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
|
||||
URLs, paths, locale, browser events, prompts, chat messages, command text,
|
||||
action output, token values, and personal information must stay explicitly
|
||||
excluded.
|
||||
7. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
|
||||
8. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
|
||||
8. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
|
||||
9. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
|
||||
These settings panels consume the privileged security-status projection,
|
||||
while the shared status type also represents intentionally sparse public and
|
||||
authenticated tiers. Privileged posture booleans therefore remain optional
|
||||
@@ -834,8 +833,8 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
|
||||
10. `frontend-modern/src/features/platformPage/PlatformWindowedList.tsx` shared with `performance-and-scalability`: the shared bounded list renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary.
|
||||
11. `frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx` shared with `performance-and-scalability`: the shared bounded table-row renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary.
|
||||
12. `frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts` shared with `performance-and-scalability`: the platform windowing controller is both a canonical frontend scroll primitive and a directional-runway performance hot path.
|
||||
9. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
|
||||
10. `frontend-modern/src/stores/aiChat.ts` shared with `ai-runtime`: the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary.
|
||||
13. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
|
||||
14. `frontend-modern/src/stores/aiChat.ts` shared with `ai-runtime`: the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary.
|
||||
Assistant session pickers and reloads must restore only safe
|
||||
`handoff_summary` presentation state from the session list. Loading a plain
|
||||
session or starting a new conversation must clear stale scoped handoff
|
||||
@@ -899,7 +898,7 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
|
||||
routes and must render as named choices without secondary raw route IDs,
|
||||
while external provider route IDs may remain visible where they disambiguate
|
||||
catalog entries.
|
||||
11. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
|
||||
15. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
|
||||
It must expose the manifest `surface_kind` field so runtime lenses such as
|
||||
`docker` are not collapsed back into owning platform semantics.
|
||||
It must also preserve canonical projection lists from the governed manifest
|
||||
@@ -907,7 +906,7 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
|
||||
`vm`, `network-share`, and `app-container` workloads through the same
|
||||
generated platform projection used by route helpers, badges, source
|
||||
filters, reportable-resource pickers, and type unions.
|
||||
12. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
|
||||
16. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
|
||||
That shared boundary must preserve `availability` as the agentless
|
||||
monitoring source for `network-endpoint` resources and settings presets,
|
||||
so source badges and platform/source type resolution do not fall back to
|
||||
@@ -1247,7 +1246,10 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
classes, table rendering, and inline close-action chrome must come from
|
||||
`detailSectionModel.ts`, `DetailSectionTable`, and `InlineDetailPanel`
|
||||
instead of local `DetailField` grids or provider-named reusable primitives.
|
||||
A compact detail row may carry optional bounded progress metadata, but the
|
||||
A compact detail row may carry optional bounded rich value content for
|
||||
links, tags, aliases, and address badges while retaining a canonical text
|
||||
value for titles, tests, and operator-readable fallback. It may also carry
|
||||
optional bounded progress metadata, but the
|
||||
shared `DetailSectionTable` must render that metadata through the CSP-safe
|
||||
`ProgressBar` while preserving the row's textual value as the primary
|
||||
operator-readable fact. Feature surfaces must omit the metadata when the
|
||||
@@ -3015,10 +3017,10 @@ verification.
|
||||
as `type: 'storage'`, so `topology === 'pool'` is the only pool
|
||||
discriminator available to the page model, and layout strings belong
|
||||
in `storage.vdevLayout`. Regression coverage: the `pool identity
|
||||
boundary` cases in
|
||||
boundary` cases in
|
||||
`frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts`
|
||||
and `shows the vdev layout as the storage kind while topology stays
|
||||
the pool discriminator` in
|
||||
the pool discriminator` in
|
||||
`frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts`.
|
||||
|
||||
43. The diagnostics export sanitizer owns the redaction boundary for the
|
||||
@@ -3029,10 +3031,10 @@ verification.
|
||||
the sanitizer's source, because the failure mode is a payload field
|
||||
added later that the sanitizer never learned about. Regression
|
||||
coverage: `redacts PBS probe failures and state reasons in the
|
||||
exported bundle` in
|
||||
exported bundle` in
|
||||
`frontend-modern/src/components/Settings/__tests__/diagnosticsModel.test.ts`
|
||||
and `keeps every PBS diagnostic failure string inside the export
|
||||
redaction boundary` in
|
||||
redaction boundary` in
|
||||
`frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`.
|
||||
|
||||
44. An org switch must leave every live query surface with data for the
|
||||
@@ -3044,11 +3046,10 @@ verification.
|
||||
refetch shape every other org-switch handler in the app already uses.
|
||||
Stale pre-switch responses are still discarded by request generation.
|
||||
Regression coverage: `refetches a constant-source query after an org
|
||||
switch` and `does not repopulate the cache when an old-org request
|
||||
resolves late` in
|
||||
switch` and `does not repopulate the cache when an old-org request
|
||||
resolves late` in
|
||||
`frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx`.
|
||||
|
||||
|
||||
## Current State
|
||||
|
||||
### Patrol objectives reuse the shared dialog, button, badge, and resource picker contracts
|
||||
@@ -3794,7 +3795,9 @@ section cards on desktop. Desktop cards share the available row width, stretch
|
||||
to the same row height, balance five- and six-section drawers across three-card
|
||||
rows, and use a bounded local label column with left-aligned values so the
|
||||
layout has no ragged fixed-width island, stranded full-width final card, or
|
||||
full-drawer scan distance. The responsive presentation stays owned by the shared primitive;
|
||||
full-drawer scan distance. Unified-resource technical summaries are part of
|
||||
this boundary and must not retain a full-width local table on desktop. The
|
||||
responsive presentation stays owned by the shared primitive;
|
||||
provider drawers must not fork their own desktop card renderers. Monitoring
|
||||
Optional detail-row progress is also owned by that shared presentation: the
|
||||
value text remains visible, `DetailSectionTable` composes `ProgressBar` for the
|
||||
@@ -6561,7 +6564,7 @@ settings away from every non-admin, and the panel is not empty for them. Proof:
|
||||
which pins the withheld, granted, and unresolved cases plus the
|
||||
`system-general` exclusion.
|
||||
|
||||
Because `DEFAULT_SETTINGS_TAB` *is* `infrastructure-systems`, the blocked-route
|
||||
Because `DEFAULT_SETTINGS_TAB` _is_ `infrastructure-systems`, the blocked-route
|
||||
fallback in `useSettingsAccess.ts` can no longer resolve to the constant — that
|
||||
sent a refused session straight back to the tab that had just refused it. The
|
||||
fallback uses an explicit preference order: the default when reachable, then
|
||||
@@ -6595,7 +6598,8 @@ fail-closed mount, failed-status, deduplicated-load, and General-fallback rules.
|
||||
|
||||
The Alerts overview may offer a compact per-resource Monitoring menu, but the
|
||||
menu is an adapter over the canonical resource operator-state API. It must
|
||||
preserve unrelated state on every write, distinguish availability-only
|
||||
preserve unrelated state on every write, including one-shot/recurring
|
||||
maintenance and descendant scope, distinguish availability-only
|
||||
expected-offline from all-attention mute, and state that retirement changes
|
||||
Pulse monitoring rather than deleting provider inventory. Resource detail and
|
||||
alert surfaces use the same typed monitoring and lifecycle vocabulary and
|
||||
|
||||
@@ -2960,7 +2960,12 @@ Monitoring supplies read-only context to the alerts-owned intent resolver. The
|
||||
operator-state adapter resolves source-native references to one canonical
|
||||
unified-resource ID before reading durable operator intent. Lookup failure,
|
||||
ambiguity, absence, or store error yields no suppression context; monitoring
|
||||
does not synthesize maintenance state.
|
||||
does not synthesize maintenance state. The adapter may traverse the live
|
||||
canonical parent chain for maintenance only. An ancestor contributes an active
|
||||
occurrence only when its scope is `resource_and_descendants`; monitoring mode,
|
||||
lifecycle state, and every other operator field remain exact-resource policy.
|
||||
When active windows overlap, the adapter projects the occurrence with the
|
||||
latest end together with its source id and inherited marker.
|
||||
|
||||
Backup-aware offline intent consumes a PVE task only when VMID, instance, and
|
||||
node match and the task is active. `pollBackupTasks` stamps server observation
|
||||
@@ -3179,7 +3184,8 @@ change beyond the shutdown defect.
|
||||
### Monitoring projects canonical resource policy into alert evaluation
|
||||
|
||||
The monitoring-owned operator-intent adapter projects `monitoringMode` and
|
||||
`lifecycleState` together with maintenance timing and the legacy compatibility
|
||||
`lifecycleState` together with effective one-shot/recurring maintenance timing
|
||||
and the legacy compatibility
|
||||
boolean. It still resolves source-native references through canonical resource
|
||||
identity before reading the store and fails open on missing, ambiguous, or
|
||||
errored identity lookup. Monitoring does not reinterpret provider ownership or
|
||||
@@ -3187,6 +3193,15 @@ invent lifecycle state; Alerts owns signal suppression and unified resources
|
||||
owns persistence. `internal/monitoring/monitor_alert_intent_test.go` and the
|
||||
alerts intent-policy proof pin this adapter boundary.
|
||||
|
||||
`internal/maintenancesentinel/` is monitoring-owned post-maintenance assurance.
|
||||
Its bounded sweep derives every concrete one-shot or recurring occurrence that
|
||||
ended in the seven-day lookback, de-duplicates on canonical resource plus exact
|
||||
occurrence end, and writes one maintenance-verification report and timeline
|
||||
record per occurrence. Restart therefore backfills recent missed recurrences
|
||||
without mutable scheduler state or duplicate reports; ancient windows remain
|
||||
out of scope. `internal/maintenancesentinel/sentinel_test.go` and
|
||||
`verification_test.go` are the focused proof.
|
||||
|
||||
### Agent privilege profile is descriptive model state
|
||||
|
||||
Host reports may carry an agent-authored privilege profile (effective root,
|
||||
|
||||
@@ -1404,6 +1404,14 @@ patrol fan-out: in a noisy-warning estate the default policy keeps the LLM-backe
|
||||
investigation path from being invoked once per warning, so the patrol queue and
|
||||
provider spend stay proportional to the alerts the operator actually opted into.
|
||||
|
||||
Operator-state mutations wired through `internal/api/router.go` must reconcile
|
||||
the active-alert set once per mutation so newly inherited parent maintenance
|
||||
takes effect immediately for every descendant. The reconciliation may inspect
|
||||
the already resident active-alert map, but it must not scan unified inventory,
|
||||
query persistence once per resource, or run on the steady-state alert hot path;
|
||||
ancestor lookup remains bounded by canonical hierarchy depth for each active
|
||||
alert actually evaluated.
|
||||
|
||||
The embedded WorkloadsSurface exposes a `compactGroupHeaders` prop on
|
||||
`frontend-modern/src/components/Workloads/useWorkloadsState.ts` that
|
||||
platform pages owning their own hosts table (Proxmox overview today) set
|
||||
|
||||
@@ -5517,6 +5517,7 @@
|
||||
"owned_prefixes": [
|
||||
"internal/availabilityprobe/",
|
||||
"internal/fleethealth/",
|
||||
"internal/maintenancesentinel/",
|
||||
"internal/monitoring/",
|
||||
"internal/storagehealth/",
|
||||
"internal/truenas/",
|
||||
@@ -5566,6 +5567,20 @@
|
||||
],
|
||||
"require_explicit_path_policy_coverage": true,
|
||||
"path_policies": [
|
||||
{
|
||||
"id": "maintenance-verification-runtime",
|
||||
"label": "post-maintenance verification monitoring proof",
|
||||
"match_prefixes": [
|
||||
"internal/maintenancesentinel/"
|
||||
],
|
||||
"match_files": [],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/maintenancesentinel/sentinel_test.go",
|
||||
"internal/maintenancesentinel/verification_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "availability-certificate-runtime",
|
||||
"label": "availability and certificate monitoring proof",
|
||||
|
||||
@@ -501,6 +501,12 @@ the `white_label` branding entitlement.
|
||||
action lifecycle, but it exposes only typed-proposal capture and gives the
|
||||
orchestrator no autonomy control, command execution, or command-shaped
|
||||
approval path.
|
||||
Operator-state mutation callbacks wired here may trigger alert
|
||||
reconciliation across active incidents so descendant-scoped maintenance
|
||||
takes effect immediately, but they receive only tenant-bound canonical
|
||||
resource identity and the already authorized mutation result. They must not
|
||||
expose inventory, operator notes, maintenance reasons, credentials, or
|
||||
cross-organization state through router callbacks or synchronization events.
|
||||
Automatic action authority is a versioned, one-use admission lease rather
|
||||
than a reusable approval. Tenant mode/license/unlock, capability safety and
|
||||
approval floor, resource allowlist/window/Never state, plan hashes, and the
|
||||
|
||||
@@ -2876,7 +2876,7 @@ completes.
|
||||
|
||||
The findings runtime reads operator-set state through the same
|
||||
durable `resource_operator_state` SQLite table on every
|
||||
new-finding-add. Both the time-bounded maintenance window and the
|
||||
new-finding-add. One-shot and timezone-aware recurring maintenance windows and the
|
||||
indefinite `IntentionallyOffline` flag persist across restarts; the
|
||||
operator commitment in either form survives without needing a
|
||||
re-entry on startup. The provider adapter returns one projection
|
||||
@@ -2885,7 +2885,7 @@ finding regardless of which signal is active.
|
||||
|
||||
The `resource_operator_state` SQLite table introduced by the
|
||||
unified-resources store keeps operator-set per-resource intent
|
||||
(intentionally offline, never auto-remediate, maintenance window,
|
||||
(intentionally offline, never auto-remediate, one-shot or recurring maintenance window and scope,
|
||||
criticality) durably alongside the rest of the unified-resource
|
||||
durable state. The `/api/resources/{id}/operator-state` API surface in
|
||||
`internal/api/resources_operator_state.go` reads and writes that table
|
||||
|
||||
@@ -120,7 +120,6 @@ NVMe percentage-used conversion is shared with storage risk: negative values
|
||||
remain unknown, while values above 100 clamp to exhausted before remaining
|
||||
life is derived.
|
||||
|
||||
|
||||
`WearoutUnreported` is the canonical absent-value sentinel for
|
||||
`PhysicalDiskMeta.Wearout` and is pinned to `-1`. Views and adapters must return
|
||||
it whenever a resource carries no physical-disk facet; returning the Go zero
|
||||
@@ -142,7 +141,7 @@ about the same disk cannot diverge.
|
||||
8. `internal/unifiedresources/metrics.go`
|
||||
9. `internal/unifiedresources/metrics_targets.go`
|
||||
10. `internal/unifiedresources/registry.go`
|
||||
10a. `internal/unifiedresources/xcpng.go`
|
||||
10a. `internal/unifiedresources/xcpng.go`
|
||||
11. `internal/unifiedresources/resolve.go`
|
||||
12. `internal/unifiedresources/resolve_context.go`
|
||||
13. `internal/unifiedresources/resolved_host_set.go`
|
||||
@@ -159,8 +158,8 @@ about the same disk cannot diverge.
|
||||
24. `internal/unifiedresources/relationships.go`
|
||||
25. `internal/unifiedresources/privacy.go`
|
||||
26. `internal/unifiedresources/actions.go`
|
||||
26a. `internal/unifiedresources/action_dispatch.go`
|
||||
26b. `internal/unifiedresources/action_dispatch_store.go`
|
||||
26a. `internal/unifiedresources/action_dispatch.go`
|
||||
26b. `internal/unifiedresources/action_dispatch_store.go`
|
||||
27. `internal/unifiedresources/audit_redaction.go`
|
||||
28. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx`
|
||||
29. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx`
|
||||
@@ -169,9 +168,9 @@ about the same disk cannot diverge.
|
||||
32. `frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx`
|
||||
33. `frontend-modern/src/features/docker/DockerConfigsTable.tsx`
|
||||
34. `frontend-modern/src/features/docker/DockerContainersTable.tsx`
|
||||
34a. `frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx`
|
||||
34b. `frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts`
|
||||
34c. `frontend-modern/src/features/docker/dockerContainerTableModel.ts`
|
||||
34a. `frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx`
|
||||
34b. `frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts`
|
||||
34c. `frontend-modern/src/features/docker/dockerContainerTableModel.ts`
|
||||
35. `frontend-modern/src/features/docker/DockerImagesTable.tsx`
|
||||
36. `frontend-modern/src/features/docker/DockerNativeTableShared.tsx`
|
||||
37. `frontend-modern/src/features/docker/DockerNetworksTable.tsx`
|
||||
@@ -188,7 +187,7 @@ about the same disk cannot diverge.
|
||||
48. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
|
||||
49. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
|
||||
50. `frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx`
|
||||
50a. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
|
||||
50a. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
|
||||
51. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`
|
||||
52. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx`
|
||||
53. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
|
||||
@@ -199,7 +198,7 @@ about the same disk cannot diverge.
|
||||
58. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts`
|
||||
59. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts`
|
||||
60. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
|
||||
60a. `frontend-modern/src/utils/workloads.ts`
|
||||
60a. `frontend-modern/src/utils/workloads.ts`
|
||||
61. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
|
||||
62. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
|
||||
63. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
|
||||
@@ -209,7 +208,7 @@ about the same disk cannot diverge.
|
||||
67. `frontend-modern/src/components/Discovery/discoveryReadiness.ts`
|
||||
68. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
|
||||
69. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
|
||||
69a. `frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts`
|
||||
69a. `frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts`
|
||||
70. `frontend-modern/src/utils/agentResources.ts`
|
||||
71. `frontend-modern/src/utils/canonicalResourceTypes.ts`
|
||||
72. `frontend-modern/src/utils/resourceBadgePresentation.ts`
|
||||
@@ -217,77 +216,77 @@ about the same disk cannot diverge.
|
||||
74. `frontend-modern/src/utils/actionAuditPresentation.ts`
|
||||
75. `frontend-modern/src/utils/resourceCorrelationPresentation.ts`
|
||||
76. `frontend-modern/src/utils/resourcePlatformData.ts`
|
||||
76. `frontend-modern/src/utils/resourcePolicyPresentation.ts`
|
||||
77. `frontend-modern/src/utils/resourceStateAdapters.ts`
|
||||
78. `frontend-modern/src/utils/resourceTypeCompat.ts`
|
||||
79. `frontend-modern/src/utils/resourceTypePresentation.ts`
|
||||
80. `frontend-modern/src/utils/serviceHealthPresentation.ts`
|
||||
81. `frontend-modern/src/utils/sourceTypePresentation.ts`
|
||||
82. `frontend-modern/src/utils/workloadTypePresentation.ts`
|
||||
83. `frontend-modern/src/utils/resourceIdentity.ts`
|
||||
84. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts`
|
||||
85. `frontend-modern/src/hooks/useUnifiedResources.ts`
|
||||
86. `frontend-modern/src/types/resource.ts`
|
||||
87. `frontend-modern/src/utils/sourcePlatforms.ts`
|
||||
88. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
|
||||
89. `internal/unifiedresources/kubernetes_metric_ids.go`
|
||||
90. `internal/unifiedresources/policy_posture.go`
|
||||
91. `frontend-modern/src/features/platformNavigation/platformNavigationModel.ts`
|
||||
91. `internal/unifiedresources/clone.go`
|
||||
92. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerPresentation.ts`
|
||||
93. `internal/unifiedresources/storage_consumers.go`
|
||||
94. `frontend-modern/src/features/standalone/standalonePageModel.ts`
|
||||
95. `frontend-modern/src/features/standalone/StandalonePageSurface.tsx`
|
||||
96. `frontend-modern/src/features/standalone/AgentsMachinesTable.tsx`
|
||||
97. `frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx`
|
||||
98. `internal/platformsupport/manifest_generated.go`
|
||||
99. `frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx`
|
||||
100. `frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx`
|
||||
101. `frontend-modern/src/features/kubernetes/kubernetesPageModel.ts`
|
||||
102. `frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx`
|
||||
103. `frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx`
|
||||
104. `frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx`
|
||||
105. `frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx`
|
||||
106. `frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx`
|
||||
107. `frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx`
|
||||
108. `frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx`
|
||||
109. `frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx`
|
||||
110. `frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx`
|
||||
111. `frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx`
|
||||
112. `frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx`
|
||||
113. `frontend-modern/src/features/docker/DockerAlertsTable.tsx`
|
||||
114. `frontend-modern/src/features/docker/DockerServicesTable.tsx`
|
||||
115. `frontend-modern/src/features/docker/DockerStorageUsageTable.tsx`
|
||||
116. `frontend-modern/src/features/actions/ActionDecisionPacket.tsx`
|
||||
117. `frontend-modern/src/features/actions/ActionReviewDialog.tsx`
|
||||
118. `frontend-modern/src/features/actions/actionPresentation.ts`
|
||||
118a. `frontend-modern/src/features/actions/actionRouting.ts`
|
||||
119. `frontend-modern/src/pages/Actions.tsx`
|
||||
120. `frontend-modern/src/routing/navigation.ts`
|
||||
121. `frontend-modern/src/routing/routePreload.ts`
|
||||
116. `frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx`
|
||||
117. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx`
|
||||
118. `frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx`
|
||||
119. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx`
|
||||
120. `frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx`
|
||||
121. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx`
|
||||
122. `frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx`
|
||||
122a. `frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts`
|
||||
122b. `frontend-modern/src/features/proxmox/proxmoxPageModel.ts`
|
||||
123. `frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx`
|
||||
124. `frontend-modern/src/features/truenas/TrueNASAppsTable.tsx`
|
||||
125. `frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx`
|
||||
126. `frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx`
|
||||
127. `frontend-modern/src/features/truenas/TrueNASServicesTable.tsx`
|
||||
128. `frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx`
|
||||
129. `frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx`
|
||||
130. `frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx`
|
||||
131. `frontend-modern/src/features/vmware/VsphereActivityTable.tsx`
|
||||
132. `frontend-modern/src/features/vmware/VsphereAlertsTable.tsx`
|
||||
133. `frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx`
|
||||
134. `frontend-modern/src/features/vmware/VsphereNetworksTable.tsx`
|
||||
135. `frontend-modern/src/features/truenas/TrueNASPageSurface.tsx`
|
||||
136. `frontend-modern/src/features/vmware/VmwarePageSurface.tsx`
|
||||
77. `frontend-modern/src/utils/resourcePolicyPresentation.ts`
|
||||
78. `frontend-modern/src/utils/resourceStateAdapters.ts`
|
||||
79. `frontend-modern/src/utils/resourceTypeCompat.ts`
|
||||
80. `frontend-modern/src/utils/resourceTypePresentation.ts`
|
||||
81. `frontend-modern/src/utils/serviceHealthPresentation.ts`
|
||||
82. `frontend-modern/src/utils/sourceTypePresentation.ts`
|
||||
83. `frontend-modern/src/utils/workloadTypePresentation.ts`
|
||||
84. `frontend-modern/src/utils/resourceIdentity.ts`
|
||||
85. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts`
|
||||
86. `frontend-modern/src/hooks/useUnifiedResources.ts`
|
||||
87. `frontend-modern/src/types/resource.ts`
|
||||
88. `frontend-modern/src/utils/sourcePlatforms.ts`
|
||||
89. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
|
||||
90. `internal/unifiedresources/kubernetes_metric_ids.go`
|
||||
91. `internal/unifiedresources/policy_posture.go`
|
||||
92. `frontend-modern/src/features/platformNavigation/platformNavigationModel.ts`
|
||||
93. `internal/unifiedresources/clone.go`
|
||||
94. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerPresentation.ts`
|
||||
95. `internal/unifiedresources/storage_consumers.go`
|
||||
96. `frontend-modern/src/features/standalone/standalonePageModel.ts`
|
||||
97. `frontend-modern/src/features/standalone/StandalonePageSurface.tsx`
|
||||
98. `frontend-modern/src/features/standalone/AgentsMachinesTable.tsx`
|
||||
99. `frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx`
|
||||
100. `internal/platformsupport/manifest_generated.go`
|
||||
101. `frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx`
|
||||
102. `frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx`
|
||||
103. `frontend-modern/src/features/kubernetes/kubernetesPageModel.ts`
|
||||
104. `frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx`
|
||||
105. `frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx`
|
||||
106. `frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx`
|
||||
107. `frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx`
|
||||
108. `frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx`
|
||||
109. `frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx`
|
||||
110. `frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx`
|
||||
111. `frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx`
|
||||
112. `frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx`
|
||||
113. `frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx`
|
||||
114. `frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx`
|
||||
115. `frontend-modern/src/features/docker/DockerAlertsTable.tsx`
|
||||
116. `frontend-modern/src/features/docker/DockerServicesTable.tsx`
|
||||
117. `frontend-modern/src/features/docker/DockerStorageUsageTable.tsx`
|
||||
118. `frontend-modern/src/features/actions/ActionDecisionPacket.tsx`
|
||||
119. `frontend-modern/src/features/actions/ActionReviewDialog.tsx`
|
||||
120. `frontend-modern/src/features/actions/actionPresentation.ts`
|
||||
118a. `frontend-modern/src/features/actions/actionRouting.ts`
|
||||
121. `frontend-modern/src/pages/Actions.tsx`
|
||||
122. `frontend-modern/src/routing/navigation.ts`
|
||||
123. `frontend-modern/src/routing/routePreload.ts`
|
||||
124. `frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx`
|
||||
125. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx`
|
||||
126. `frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx`
|
||||
127. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx`
|
||||
128. `frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx`
|
||||
129. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx`
|
||||
130. `frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx`
|
||||
122a. `frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts`
|
||||
122b. `frontend-modern/src/features/proxmox/proxmoxPageModel.ts`
|
||||
131. `frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx`
|
||||
132. `frontend-modern/src/features/truenas/TrueNASAppsTable.tsx`
|
||||
133. `frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx`
|
||||
134. `frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx`
|
||||
135. `frontend-modern/src/features/truenas/TrueNASServicesTable.tsx`
|
||||
136. `frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx`
|
||||
137. `frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx`
|
||||
138. `frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx`
|
||||
139. `frontend-modern/src/features/vmware/VsphereActivityTable.tsx`
|
||||
140. `frontend-modern/src/features/vmware/VsphereAlertsTable.tsx`
|
||||
141. `frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx`
|
||||
142. `frontend-modern/src/features/vmware/VsphereNetworksTable.tsx`
|
||||
143. `frontend-modern/src/features/truenas/TrueNASPageSurface.tsx`
|
||||
144. `frontend-modern/src/features/vmware/VmwarePageSurface.tsx`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -845,12 +844,12 @@ container inventory table.
|
||||
14. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx` shared with `storage-recovery`: Proxmox recoverable workload table rows are both a storage/recovery coverage surface and a unified-resource platform-table consumer boundary.
|
||||
15. `frontend-modern/src/routing/routePreload.ts` shared with `frontend-primitives`, `performance-and-scalability`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
|
||||
16. `frontend-modern/src/stores/websocket-global.ts` shared with `performance-and-scalability`: the process-wide realtime store owner is both a unified-resource state boundary and a fleet-scale connection and reconciliation hot path.
|
||||
16. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `frontend-primitives`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
|
||||
17. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `frontend-primitives`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
|
||||
It must carry the manifest `surface_kind` distinction so `docker` remains
|
||||
machine-readable as a `runtime-lens` while owning infrastructure sources
|
||||
remain `platform` entries.
|
||||
17. `frontend-modern/src/utils/resourceStateAdapters.ts` shared with `performance-and-scalability`: canonical resource compatibility and host coalescence are both a unified-resource contract and a fleet-scale reconciliation hot path.
|
||||
18. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `frontend-primitives`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
|
||||
18. `frontend-modern/src/utils/resourceStateAdapters.ts` shared with `performance-and-scalability`: canonical resource compatibility and host coalescence are both a unified-resource contract and a fleet-scale reconciliation hot path.
|
||||
19. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `frontend-primitives`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
|
||||
That shared vocabulary boundary owns the generic `docker` platform label:
|
||||
selectors, badges, and filter options render it as "Docker / Podman" so
|
||||
v5 Docker users can still find the runtime surface while Podman-backed
|
||||
@@ -872,8 +871,8 @@ container inventory table.
|
||||
display/source family; `platformScopes` is the overlap set used when a
|
||||
runtime workload belongs to both Docker and an owning infrastructure
|
||||
platform.
|
||||
19. `frontend-modern/src/utils/workloads.ts` shared with `performance-and-scalability`: the stable workload metadata identity helper is both a unified-resource persistence boundary and a workloads hot-path lookup boundary.
|
||||
20. `internal/api/resourceapi/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
20. `frontend-modern/src/utils/workloads.ts` shared with `performance-and-scalability`: the stable workload metadata identity helper is both a unified-resource persistence boundary and a workloads hot-path lookup boundary.
|
||||
21. `internal/api/resourceapi/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
`/api/resources` type filters must accept URL-encoded comma-separated lists
|
||||
from browser query builders exactly like literal comma separators, so Docker
|
||||
/ Podman runtime pages do not lose `docker-host` inventory while requesting
|
||||
@@ -887,12 +886,13 @@ container inventory table.
|
||||
id over host labels when building host-level Discovery targets, so detail
|
||||
drawers, websocket hydration, and API lookups use the same identity.
|
||||
The global resource timeline is also owned at this boundary. `GET
|
||||
/api/resources/timeline` may expose provider-wide `ResourceChange` records
|
||||
/api/resources/timeline` may expose provider-wide `ResourceChange` records
|
||||
for platform pages before a single resource drawer is selected, but those
|
||||
records must still come from the canonical resource-change store and use
|
||||
the same filter parser as per-resource timelines. Relationship-aware
|
||||
expansion remains a per-resource timeline behavior; unscoped provider
|
||||
activity must not infer related resources in the frontend.
|
||||
|
||||
## Extension Points
|
||||
|
||||
The global Product Trust projection is owned at
|
||||
@@ -1128,9 +1128,9 @@ cannot create a browser mutation.
|
||||
tables must not render ConfigMap or Secret payload values, and metadata-only
|
||||
rows must not expose key names as if payload fields had been read.
|
||||
2. Add typed accessors and views in `internal/unifiedresources/views.go`
|
||||
Resource detail mappers now reuse the shared
|
||||
`frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor
|
||||
labels so the canonical unified-resource presentation layer owns the wording.
|
||||
Resource detail mappers now reuse the shared
|
||||
`frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor
|
||||
labels so the canonical unified-resource presentation layer owns the wording.
|
||||
|
||||
The canonical AI-safe summary builder now owns the sensitivity-specific suffix
|
||||
phrases for `sensitive` and `restricted` resources, so the backend policy
|
||||
@@ -1148,350 +1148,342 @@ Canonical policy posture aggregation is owned here as well. Resource API
|
||||
payloads may expose a camelCase transport projection, but the counts must be
|
||||
derived from `internal/unifiedresources/policy_posture.go` after canonical
|
||||
policy metadata has been refreshed, not recomputed from frontend labels,
|
||||
AI-only summary payloads, or page-local heuristics.
|
||||
4. Add metrics-target normalization, surface-friendly projections of
|
||||
nested source payloads, or synthetic metrics support through
|
||||
`internal/unifiedresources/metrics_targets.go`,
|
||||
`internal/unifiedresources/metrics.go`, and the relevant adapter in
|
||||
`internal/unifiedresources/adapters.go`. The unified `Resource` shape
|
||||
carries top-level `Uptime` and `Temperature` projections so frontend
|
||||
tables that render those columns do not have to dig into per-source
|
||||
payloads (`agent.uptimeSeconds`, `proxmox.uptime`,
|
||||
`agent.temperature`, `proxmox.temperature`); adapters that wrap an
|
||||
`AgentData` or `ProxmoxData` must populate those top-level fields
|
||||
from the nested source values, and adapters for resource types that
|
||||
have no native uptime/temperature concept (e.g. `k8s-deployment`,
|
||||
`k8s-replicaset`, `k8s-configmap`, `k8s-secret`,
|
||||
`docker-service`, `k8s-cluster` aggregates) must leave them unset so
|
||||
bespoke platform-page tables can hide the column entirely.
|
||||
Kubernetes deployment metrics live on the canonical adapter through
|
||||
`metricsFromKubernetesDeployment(cluster, deployment)`. Upstream
|
||||
Deployments do not expose CPU / memory natively because they are
|
||||
scheduling abstractions over their controlled pods, so the helper
|
||||
returns nil for non-mock runtimes (until the adapter aggregates pod
|
||||
metrics into the owning deployment) and synthesizes deployment-stable
|
||||
CPU / memory / disk / network values for mock mode so the
|
||||
platform-page Deployments table renders meaningful operator values
|
||||
instead of dashes. The synthetic branch is gated by
|
||||
`mockmode.IsEnabled()` and scales with the deployment's
|
||||
ready/desired/available replica state so degraded deployments read as
|
||||
elevated pressure on the surviving replicas.
|
||||
Namespaced Kubernetes adapters share one Resource scaffold: a new
|
||||
namespaced kind populates its kind-specific `K8sData` fields (after
|
||||
`baseKubernetesData`) and delegates Resource assembly and identity to
|
||||
`namespacedKubernetesResource(cluster, clusterName, namespace, name,
|
||||
AI-only summary payloads, or page-local heuristics. 4. Add metrics-target normalization, surface-friendly projections of
|
||||
nested source payloads, or synthetic metrics support through
|
||||
`internal/unifiedresources/metrics_targets.go`,
|
||||
`internal/unifiedresources/metrics.go`, and the relevant adapter in
|
||||
`internal/unifiedresources/adapters.go`. The unified `Resource` shape
|
||||
carries top-level `Uptime` and `Temperature` projections so frontend
|
||||
tables that render those columns do not have to dig into per-source
|
||||
payloads (`agent.uptimeSeconds`, `proxmox.uptime`,
|
||||
`agent.temperature`, `proxmox.temperature`); adapters that wrap an
|
||||
`AgentData` or `ProxmoxData` must populate those top-level fields
|
||||
from the nested source values, and adapters for resource types that
|
||||
have no native uptime/temperature concept (e.g. `k8s-deployment`,
|
||||
`k8s-replicaset`, `k8s-configmap`, `k8s-secret`,
|
||||
`docker-service`, `k8s-cluster` aggregates) must leave them unset so
|
||||
bespoke platform-page tables can hide the column entirely.
|
||||
Kubernetes deployment metrics live on the canonical adapter through
|
||||
`metricsFromKubernetesDeployment(cluster, deployment)`. Upstream
|
||||
Deployments do not expose CPU / memory natively because they are
|
||||
scheduling abstractions over their controlled pods, so the helper
|
||||
returns nil for non-mock runtimes (until the adapter aggregates pod
|
||||
metrics into the owning deployment) and synthesizes deployment-stable
|
||||
CPU / memory / disk / network values for mock mode so the
|
||||
platform-page Deployments table renders meaningful operator values
|
||||
instead of dashes. The synthetic branch is gated by
|
||||
`mockmode.IsEnabled()` and scales with the deployment's
|
||||
ready/desired/available replica state so degraded deployments read as
|
||||
elevated pressure on the surviving replicas.
|
||||
Namespaced Kubernetes adapters share one Resource scaffold: a new
|
||||
namespaced kind populates its kind-specific `K8sData` fields (after
|
||||
`baseKubernetesData`) and delegates Resource assembly and identity to
|
||||
`namespacedKubernetesResource(cluster, clusterName, namespace, name,
|
||||
resourceType, status, data, labels)` in
|
||||
`internal/unifiedresources/adapters.go` instead of hand-rolling the
|
||||
`Resource{...}` literal plus `namespacedKubernetesIdentity` return.
|
||||
The scaffold owns `Technology: "kubernetes"`, `LastSeen` from the
|
||||
cluster, `UpdatedAt`, the `Kubernetes` facet pointer, and label-derived
|
||||
tags; only cluster-scoped or non-namespaced kinds (cluster, node, PV,
|
||||
StorageClass, namespace itself) keep bespoke identity construction.
|
||||
5. Add platform registry, resolution, host-dedup, or monitored-system
|
||||
projection behavior through `internal/unifiedresources/registry.go`,
|
||||
`internal/unifiedresources/resolve.go`,
|
||||
`internal/unifiedresources/resolved_host_set.go`,
|
||||
`internal/unifiedresources/snapshot_source_filter.go`,
|
||||
`internal/unifiedresources/store.go`,
|
||||
`internal/unifiedresources/kubernetes_capabilities.go`,
|
||||
`internal/unifiedresources/pbs_rollups.go`,
|
||||
`internal/unifiedresources/monitored_systems.go`,
|
||||
`internal/unifiedresources/monitored_system_projection.go`, and
|
||||
the shared list-order helpers consumed by `internal/api/resourceapi/resources.go`;
|
||||
canonical unified-resource lists must preserve one deterministic
|
||||
`name -> type -> id` order across registry reads, REST pagination, and
|
||||
websocket-backed refreshes so equal-name resources do not silently reshuffle
|
||||
between cold hydrate and later runtime updates
|
||||
Realtime delta reconciliation must preserve exact display-object identity
|
||||
for untouched non-host resources, canonicalize changed and newly added rows,
|
||||
and re-evaluate exactly the host-merge groups the delta could have altered:
|
||||
a group refreshes when a flagged id names one of its current members, and a
|
||||
flagged id absent from the incoming snapshot (a removal, or a partner id an
|
||||
earlier coalesce folded away) conservatively refreshes every group. A tick
|
||||
that flags no member of a group must preserve that group's cached merged
|
||||
host row by object identity. Incremental and
|
||||
full-snapshot paths must therefore produce the same canonical host identity,
|
||||
labels, and compatibility fields without cloning the entire estate per tick.
|
||||
The connection store publishes each reconciliation's changed IDs and resource
|
||||
revision. `useUnifiedResources` applies that revision to the shared
|
||||
all-resources cache once and derives type-filtered route projections from the
|
||||
canonical result. An instance observing a revision the shared cache already
|
||||
holds must not re-read or deep-unwrap the realtime store, and the merging
|
||||
instance dereferences raw store subtrees only for rows the delta merge will
|
||||
clone (flagged ids, host-merge members, and ids absent from the shared
|
||||
cache). A sequential revision with unchanged route membership
|
||||
patches only the changed row indices plus the bounded agent coalescing set.
|
||||
The connection store retains a bounded per-revision changed-id history; an
|
||||
instance that resumes several revisions behind the shared cache must catch
|
||||
up through the unioned changed-id set as an incremental delta merge whenever
|
||||
the history covers the gap, so tab entry and re-entry do not deep-unwrap or
|
||||
remerge the full estate. Only initial hydration, uncovered revision gaps,
|
||||
full-snapshot commits, additions, removals, or reorderings
|
||||
fall back to keyed full reconciliation.
|
||||
Each reconciliation also records the per-resource top-level keys its merge
|
||||
patches touched (`platformData` expanded one level), published with the
|
||||
revision and unioned across the history window and the hidden-tab deferral
|
||||
set with unknown-shape contamination. A changed non-host row whose recorded
|
||||
keys stay within the pass-through metric fields, the `proxmox` facet
|
||||
mirror, and the `platformData` metric mirror leaves takes a fast merge
|
||||
path: the previous display row with only the patched subtrees cloned in,
|
||||
bypassing the full clone-canonicalize-merge, and committing to the
|
||||
connection store and instance projections as per-key subtree writes rather
|
||||
than whole-row keyed reconciles. The fast output must stay
|
||||
content-equivalent to the full path (facet keeps, deletion semantics, and
|
||||
default-policy synthesis included), must never adopt raw-baseline subtrees
|
||||
by reference, and any row outside the allow-list — including agent rows,
|
||||
whose output can depend on host coalescing — must take the full path. Route-prefetch and route-realtime
|
||||
activation are separate:
|
||||
a prefetched hidden surface may retain REST data without subscribing its full
|
||||
projection to every realtime tick, and activation catches up from the shared
|
||||
cache. Richer REST-only facets are promoted into that cache before thinner
|
||||
realtime deltas are applied, so the optimization cannot discard disk I/O,
|
||||
PBS, policy, or provider metadata.
|
||||
Broadcast payload slimming is reversed at the connection-store ingestion
|
||||
boundary, before any canonical merge or consumer read: `capabilitiesRef` is
|
||||
expanded into per-row inline `capabilities` through the state payload's
|
||||
`capabilityCatalog` (per-row clones, because store reconciliation mutates
|
||||
adopted objects in place), and a resource arriving without a policy is given
|
||||
a synthesized default posture (internal sensitivity, cloud-summary routing,
|
||||
no redactions) so a posture transition patched as `policy: null` cannot
|
||||
leave a stale governed policy behind. Client identity-alias resolution must
|
||||
consult `canonicalIdentity.supersededIds` explicitly, because broadcast
|
||||
aliases no longer duplicate superseded canonical ids.
|
||||
That same unified-resource owner also defines the canonical transport
|
||||
projection for operator-facing resources: `/api/resources` and websocket
|
||||
`state.resources` must share `ContractResourceType`, canonical display
|
||||
names, and canonical cluster labels instead of publishing separate REST and
|
||||
broadcast aliases for the same machine.
|
||||
Fleet command posture that reaches resource-facing rows must remain a
|
||||
projection of `/api/connections` `fleet.commandPolicy`: desired server
|
||||
policy, applied agent truth, enforcement, and reason stay separate. Unified
|
||||
resource consumers may show compact remote-control status, but they must not
|
||||
treat top-level `remoteControl` as applied agent runtime truth, and they
|
||||
must preserve desired/applied drift or no-report attention when enriching
|
||||
resource rows.
|
||||
Platform-page stale-agent notices may consume canonical agent identity from
|
||||
merged resources only to scope the Infrastructure settings update-command
|
||||
route to the affected agents. That scoped lifecycle handoff must not become a
|
||||
new resource-action authority, a page-local command runner, or a substitute
|
||||
for the `/api/connections` fleet command-policy truth described above.
|
||||
Resource consumers must also use the API-owned agent update target when
|
||||
comparing resource-carried agent versions; the running app build version is
|
||||
not a resource freshness contract.
|
||||
Kubernetes node rows are cluster-agent-backed for this purpose: even when a
|
||||
canonical `k8s-node` row is a pure Kubernetes API projection with no merged
|
||||
`agent` facet, `internal/unifiedresources/adapters.go` must carry the
|
||||
cluster `AgentID` and cluster-scoped `AgentVersion` on the row's
|
||||
Kubernetes facet so platform consumers can scope stale-agent notices and
|
||||
update-command links from typed resource evidence instead of rebuilding
|
||||
ownership from the parent cluster row.
|
||||
`internal/unifiedresources/top_level_systems.go`
|
||||
Explicit linked-host correlation is canonical here: when Kubernetes node
|
||||
ingest has a resolved backing host agent, the registry must merge that node
|
||||
into the agent resource instead of publishing duplicate top-level
|
||||
infrastructure rows for the same machine under both `agent` and `k8s-node`
|
||||
identities.
|
||||
Canonical read-state overlays belong here as well: when monitoring or a
|
||||
preview path needs to project extra source-native records onto an existing
|
||||
settled read state, it must do so through
|
||||
`internal/unifiedresources/monitor_adapter.go` and
|
||||
`internal/unifiedresources/registry.go` so matcher seeding, manual links,
|
||||
and merge semantics stay unified-resource-owned instead of being rebuilt in
|
||||
consumers.
|
||||
Storage consumer projection is unified-resource-owned through
|
||||
`internal/unifiedresources/storage_consumers.go`. When a provider publishes
|
||||
source-native storage consumer metadata that cannot be derived from shared
|
||||
Proxmox/PBS relationship indexes, refresh must preserve that source-owned
|
||||
consumer count, consumer type list, and top-consumer summary on the
|
||||
canonical storage resource unless a stronger shared consumer projection has
|
||||
already populated those fields in the same refresh.
|
||||
Operator-facing storage posture wording is part of that same ownership:
|
||||
when multiple storage-risk reasons exist, shared posture helpers must prefer
|
||||
the most decision-useful protection loss summary such as lost parity over a
|
||||
generic disk-count aggregate, so resource drawers and incidents do not hide
|
||||
the actual protection boundary behind a broader count phrase.
|
||||
6. Add canonical governed name-resolution or policy-aware resource lookup behavior through `internal/unifiedresources/resolve.go` and `internal/unifiedresources/resolve_context.go`
|
||||
8. Add or change discovery-support runtime under the resource drawer through `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` for shell/presentation ownership, `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts` for fetch, websocket-progress, manual-run triggering, and notes-mutation ownership, and `frontend-modern/src/components/Discovery/discoveryReadiness.ts` for the shared readiness verdict used by resource-drawer Discovery surfaces. Embedded drawers may expose the top-level run action through this shared Discovery tab, but they must still call the canonical discovery trigger state path instead of introducing drawer-local API mutations.
|
||||
Drawer-level feature availability belongs to
|
||||
`frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts`.
|
||||
It consumes the shared AI runtime settings store and fails closed until the
|
||||
runtime explicitly reports `discovery_enabled=true`. Resource, guest, node,
|
||||
and Docker host drawers must use that boundary for every Discovery tab,
|
||||
readiness badge, analysis reveal, identified-service suggestion, and
|
||||
passive discovery-record query. A disabled or unresolved feature must leave
|
||||
no Discovery mention in drawer chrome or content and must not start a
|
||||
drawer-local discovery read.
|
||||
Resource drawer secondary sections, action history, discovery run summaries,
|
||||
and other compact resource-detail cards may own their resource-specific
|
||||
labels, rows, filters, and actions, but the repeated bordered compact frame
|
||||
is a frontend-primitives boundary. `ResourceDetailDrawerOverviewTab.tsx`,
|
||||
`ResourceActionHistory.tsx`, and `DiscoveryTab.tsx` must compose
|
||||
`InfoCardFrame` for that shell instead of restoring local card-frame
|
||||
classes.
|
||||
Curated technical inventory follows the shared compact-row contract instead
|
||||
of the secondary-card contract. Docker-host drawers must project system,
|
||||
runtime, memory, storage, and telemetry facts through
|
||||
`TechnicalDetailsSection` with canonical `DetailSection[]` data, while the
|
||||
unified-resource drawer keeps its existing compact technical summary tables
|
||||
visible. Only genuinely large or interactive provider-support content stays
|
||||
lazy behind `TechnicalDetailsDisclosure`; technical inventory must not
|
||||
restore a local card mosaic or add a drawer-open fetch.
|
||||
9. Keep dashboard and infrastructure freshness on the canonical unified-resource
|
||||
ownership path. `frontend-modern/src/stores/websocket.ts`,
|
||||
`frontend-modern/src/utils/resourceStateAdapters.ts`, and
|
||||
`frontend-modern/src/hooks/useUnifiedResources.ts` together own the frontend
|
||||
canonicalization boundary: REST may hydrate the initial snapshot and
|
||||
unsupported filtered queries, but supported snapshot freshness must come
|
||||
from websocket `state.resources` instead of layering confirmatory
|
||||
route-local REST refetch loops over already-owned resource
|
||||
updates.
|
||||
Oversized WebSocket recovery is the transport exception: a complete
|
||||
`/api/state` response may refresh display state while the connection remains
|
||||
baseline-free, but resource deltas must not patch that independently built
|
||||
REST snapshot. The store resumes delta application only after the same
|
||||
connection delivers a complete WebSocket resource snapshot.
|
||||
Browser WebSocket liveness tracking is part of that same store boundary:
|
||||
valid inbound server messages, including heartbeat `ping`/`pong` traffic,
|
||||
must refresh the browser-side activity timestamp so quiet periods between
|
||||
resource snapshots do not cause avoidable reconnect churn.
|
||||
That shared store/adapter/hook path must also preserve canonical row shape
|
||||
across transport boundaries: thinner realtime `state.resources` payloads
|
||||
must merge into the existing canonical resource snapshot instead of
|
||||
downgrading richer REST-only infrastructure details such as disk I/O, source
|
||||
metadata, or platform summary fields after first hydrate. For default
|
||||
Source lists and their source-specific facets are the exception: a current
|
||||
snapshot with canonical source evidence replaces stale source lists and
|
||||
removes provider facets that no longer have matching source evidence, so
|
||||
rows do not keep displaying a previous platform identity after websocket
|
||||
refreshes. For default
|
||||
`initialHydration: 'immediate'` consumers, that same path must not paint the
|
||||
thinner websocket transport before the first canonical REST snapshot exists;
|
||||
only explicit websocket-first consumers may render directly from the realtime
|
||||
transport before canonical hydrate completes. Operator surfaces that must
|
||||
preserve already-known infrastructure continuity after login, such as the
|
||||
Infrastructure page, must use websocket-first hydration with stale-cache
|
||||
REST revalidation after the first-paint settle window so the page can paint
|
||||
from live state immediately without forcing a second resource-shape
|
||||
transition while summary and table surfaces are still mounting.
|
||||
Org-scope and enabled-state transitions in
|
||||
`frontend-modern/src/hooks/useUnifiedResources.ts` must invalidate older
|
||||
in-flight REST refreshes before publishing the new scoped cache entry, so a
|
||||
stale request cannot set active-scope errors, clear the active request guard,
|
||||
or replace the currently mounted Infrastructure/Workloads resource snapshot.
|
||||
Canonical cluster membership in that shared path must come only from
|
||||
explicit cluster identity such as Kubernetes context or platform cluster
|
||||
labels; standalone resource names must never be repurposed as synthetic
|
||||
`clusterId` values.
|
||||
13. Keep operator-facing resource analysis vocabulary task-first on unified-resource
|
||||
surfaces. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx`,
|
||||
`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`,
|
||||
and `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` may expose
|
||||
provider identity or governed safe-summary posture when that context helps
|
||||
an operator, but the rendered labels must stay product-neutral and use
|
||||
`Analysis`, `Analysis Reasoning`, and `Safe Summary` rather than reviving
|
||||
generic `AI` or `AI-Safe` branding inside the resource drawer or discovery
|
||||
shell.
|
||||
14. Keep the operator-facing unified resource table width-aware at the table
|
||||
surface, not just at the browser viewport. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx`
|
||||
must route its root ref through `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`,
|
||||
and `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
|
||||
owns the column-priority breakpoints for host and service infrastructure
|
||||
rows. When the app shell leaves tablet-sized space during live resize, the
|
||||
table hides lower-priority metadata first. At phone width, the state model
|
||||
must remove the old 640-pixel floor, preserve identity at exactly 30 percent
|
||||
of the table, and allocate the remaining width across the bounded
|
||||
source-relevant health and activity columns. Both the document and table
|
||||
shell must remain free of horizontal overflow; desktop and tablet stages
|
||||
retain their existing complete column contracts.
|
||||
15. Keep shared policy-posture framing on the unified-resource card owner.
|
||||
`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
|
||||
may accept caller-owned subtitle or resource-count wording when Patrol or
|
||||
another shared surface needs to explain how the same governed policy counts
|
||||
should be read, but those framing lines must extend the shared card API
|
||||
rather than spawning page-local policy summary shells.
|
||||
16. Keep platform/runtime top-level route paths on the canonical resource-link
|
||||
helper. `frontend-modern/src/routing/resourceLinks.ts` owns the
|
||||
`STANDALONE_PATH`, `DOCKER_PATH`, `KUBERNETES_PATH`, `TRUENAS_PATH`,
|
||||
`VMWARE_PATH`, `PATROL_PATH`, `PATROL_CONTROL_ANCHOR`,
|
||||
`PATROL_CONTROL_PATH`, and `PATROL_CONTROL_STARTER_QUERY_PARAM` constants,
|
||||
the route-backed Patrol control starter helpers, and the `buildStandalonePath`,
|
||||
`buildDockerPath`, `buildKubernetesPath`, `buildTrueNASPath`,
|
||||
`buildVmwarePath` builders.
|
||||
Per-platform surfaces and tab specs must
|
||||
derive every internal link from those builders so the canonical resource
|
||||
URL vocabulary stays single-sourced; ad hoc string concatenation of
|
||||
platform routes inside feature directories is not permitted. The canonical
|
||||
Pulse Intelligence external-agent hash
|
||||
`/settings/pulse-intelligence/assistant#external-agent-setup` and legacy
|
||||
`/settings/security/api#external-agent-setup` /
|
||||
`/settings/security/api#pulse-mcp-setup` compatibility hashes may live in
|
||||
the shared route helper, but they are adjacent settings route state, not
|
||||
unified-resource identity, platform scope, or drawer focus state.
|
||||
The Patrol `patrolControlStarter=patrol_control` query is an adjacent
|
||||
first-party Patrol control handoff flag, with legacy
|
||||
`operationsLoopStarter` values accepted only as compatibility aliases, not
|
||||
unified-resource filters or focus keys. Unified-resource consumers must not reuse those values for resource
|
||||
identity, list filtering, contextual focus, storage state, recovery state,
|
||||
or platform scoping.
|
||||
The user-facing Machines surface's default resource route is the machines projection
|
||||
(`/standalone/machines`); agentless endpoint rows use the
|
||||
`/standalone/availability` projection and must not be collapsed into a
|
||||
generic overview URL.
|
||||
The frontend-primitives-owned Machines IA contract consumes the
|
||||
unified-resource projection for Pulse-managed standalone agent rows and
|
||||
agentless availability endpoint rows; this subsystem owns only the
|
||||
membership rules for those projected rows. Agent membership must require
|
||||
`resource.type === "agent"`, canonical Pulse-agent source evidence from
|
||||
resource sources or source status, and no stronger provider-owner evidence
|
||||
from Proxmox, VMware, TrueNAS, or Kubernetes. Source-less legacy snapshots
|
||||
may fall back to a normalized `platformType === "agent"`, but
|
||||
provider-owned nodes must not become machine-page members through
|
||||
hostname, `agent` platform scope, or agent telemetry alone; those facts
|
||||
surface as facets on the owning provider page.
|
||||
`AgentsMachinesTable.tsx` may own row membership, resource-derived menu
|
||||
eligibility, evidence-gated column relevance, and remove-agent semantics
|
||||
for these projected rows. In particular, the GPU metric column and its
|
||||
View choice are relevant only when at least one projected machine reports
|
||||
finite GPU utilization; absent telemetry must not produce an empty default
|
||||
column or a no-op column choice, while persisted visibility remains ready
|
||||
for the column when evidence later appears. The
|
||||
compact row action trigger chrome stays under the frontend-primitives
|
||||
`ActionIconButton` boundary rather than becoming a unified-resource-local
|
||||
button shell.
|
||||
Machines list search and online-state narrowing are frontend route state,
|
||||
not new unified-resource membership fields. `StandalonePageSurface.tsx`
|
||||
owns the `STANDALONE_QUERY_PARAMS` query/status projection and one composite
|
||||
reset, while `AgentsMachinesTable.tsx` consumes those controlled values so
|
||||
saved links and bookmarks cannot diverge from the canonical projected row
|
||||
set. Those query parameters must only narrow the already-owned agent
|
||||
projection; they must not cause provider nodes or availability endpoints to
|
||||
enter the Machines membership bucket.
|
||||
`PROXMOX_BACKUPS_QUERY_PARAMS` in the same shared route-helper module is
|
||||
storage/recovery-owned filter and workspace state for the Proxmox Backups
|
||||
surface. Its query, view, node, type, source, posture, and day keys may
|
||||
narrow already-correlated backup rows, but they do not add unified-resource
|
||||
membership, change canonical workload identity, or turn recovery evidence
|
||||
into a provider-resource projection.
|
||||
The Proxmox backup workspace's chronological and coverage views are
|
||||
canonical route state under `/proxmox/backups/date` and
|
||||
`/proxmox/backups/coverage`. The shared route helper owns those path
|
||||
builders; the backup surface must use the shared platform section-tab
|
||||
primitive for navigation and may retain legacy query parsing only as
|
||||
compatibility input. Those view paths describe backup evidence and must
|
||||
not be treated as unified-resource membership or workload identity.
|
||||
The default tab for each platform path must point at a sub-tab whose
|
||||
canonical unified-resource projection actually populates, and visible
|
||||
workflow subtabs must stay evidence-gated by the same canonical row or
|
||||
signal source instead of advertising empty object browsers. The
|
||||
canonical TrueNAS adapter (`internal/truenas/provider.go::
|
||||
`internal/unifiedresources/adapters.go` instead of hand-rolling the
|
||||
`Resource{...}` literal plus `namespacedKubernetesIdentity` return.
|
||||
The scaffold owns `Technology: "kubernetes"`, `LastSeen` from the
|
||||
cluster, `UpdatedAt`, the `Kubernetes` facet pointer, and label-derived
|
||||
tags; only cluster-scoped or non-namespaced kinds (cluster, node, PV,
|
||||
StorageClass, namespace itself) keep bespoke identity construction. 5. Add platform registry, resolution, host-dedup, or monitored-system
|
||||
projection behavior through `internal/unifiedresources/registry.go`,
|
||||
`internal/unifiedresources/resolve.go`,
|
||||
`internal/unifiedresources/resolved_host_set.go`,
|
||||
`internal/unifiedresources/snapshot_source_filter.go`,
|
||||
`internal/unifiedresources/store.go`,
|
||||
`internal/unifiedresources/kubernetes_capabilities.go`,
|
||||
`internal/unifiedresources/pbs_rollups.go`,
|
||||
`internal/unifiedresources/monitored_systems.go`,
|
||||
`internal/unifiedresources/monitored_system_projection.go`, and
|
||||
the shared list-order helpers consumed by `internal/api/resourceapi/resources.go`;
|
||||
canonical unified-resource lists must preserve one deterministic
|
||||
`name -> type -> id` order across registry reads, REST pagination, and
|
||||
websocket-backed refreshes so equal-name resources do not silently reshuffle
|
||||
between cold hydrate and later runtime updates
|
||||
Realtime delta reconciliation must preserve exact display-object identity
|
||||
for untouched non-host resources, canonicalize changed and newly added rows,
|
||||
and re-evaluate exactly the host-merge groups the delta could have altered:
|
||||
a group refreshes when a flagged id names one of its current members, and a
|
||||
flagged id absent from the incoming snapshot (a removal, or a partner id an
|
||||
earlier coalesce folded away) conservatively refreshes every group. A tick
|
||||
that flags no member of a group must preserve that group's cached merged
|
||||
host row by object identity. Incremental and
|
||||
full-snapshot paths must therefore produce the same canonical host identity,
|
||||
labels, and compatibility fields without cloning the entire estate per tick.
|
||||
The connection store publishes each reconciliation's changed IDs and resource
|
||||
revision. `useUnifiedResources` applies that revision to the shared
|
||||
all-resources cache once and derives type-filtered route projections from the
|
||||
canonical result. An instance observing a revision the shared cache already
|
||||
holds must not re-read or deep-unwrap the realtime store, and the merging
|
||||
instance dereferences raw store subtrees only for rows the delta merge will
|
||||
clone (flagged ids, host-merge members, and ids absent from the shared
|
||||
cache). A sequential revision with unchanged route membership
|
||||
patches only the changed row indices plus the bounded agent coalescing set.
|
||||
The connection store retains a bounded per-revision changed-id history; an
|
||||
instance that resumes several revisions behind the shared cache must catch
|
||||
up through the unioned changed-id set as an incremental delta merge whenever
|
||||
the history covers the gap, so tab entry and re-entry do not deep-unwrap or
|
||||
remerge the full estate. Only initial hydration, uncovered revision gaps,
|
||||
full-snapshot commits, additions, removals, or reorderings
|
||||
fall back to keyed full reconciliation.
|
||||
Each reconciliation also records the per-resource top-level keys its merge
|
||||
patches touched (`platformData` expanded one level), published with the
|
||||
revision and unioned across the history window and the hidden-tab deferral
|
||||
set with unknown-shape contamination. A changed non-host row whose recorded
|
||||
keys stay within the pass-through metric fields, the `proxmox` facet
|
||||
mirror, and the `platformData` metric mirror leaves takes a fast merge
|
||||
path: the previous display row with only the patched subtrees cloned in,
|
||||
bypassing the full clone-canonicalize-merge, and committing to the
|
||||
connection store and instance projections as per-key subtree writes rather
|
||||
than whole-row keyed reconciles. The fast output must stay
|
||||
content-equivalent to the full path (facet keeps, deletion semantics, and
|
||||
default-policy synthesis included), must never adopt raw-baseline subtrees
|
||||
by reference, and any row outside the allow-list — including agent rows,
|
||||
whose output can depend on host coalescing — must take the full path. Route-prefetch and route-realtime
|
||||
activation are separate:
|
||||
a prefetched hidden surface may retain REST data without subscribing its full
|
||||
projection to every realtime tick, and activation catches up from the shared
|
||||
cache. Richer REST-only facets are promoted into that cache before thinner
|
||||
realtime deltas are applied, so the optimization cannot discard disk I/O,
|
||||
PBS, policy, or provider metadata.
|
||||
Broadcast payload slimming is reversed at the connection-store ingestion
|
||||
boundary, before any canonical merge or consumer read: `capabilitiesRef` is
|
||||
expanded into per-row inline `capabilities` through the state payload's
|
||||
`capabilityCatalog` (per-row clones, because store reconciliation mutates
|
||||
adopted objects in place), and a resource arriving without a policy is given
|
||||
a synthesized default posture (internal sensitivity, cloud-summary routing,
|
||||
no redactions) so a posture transition patched as `policy: null` cannot
|
||||
leave a stale governed policy behind. Client identity-alias resolution must
|
||||
consult `canonicalIdentity.supersededIds` explicitly, because broadcast
|
||||
aliases no longer duplicate superseded canonical ids.
|
||||
That same unified-resource owner also defines the canonical transport
|
||||
projection for operator-facing resources: `/api/resources` and websocket
|
||||
`state.resources` must share `ContractResourceType`, canonical display
|
||||
names, and canonical cluster labels instead of publishing separate REST and
|
||||
broadcast aliases for the same machine.
|
||||
Fleet command posture that reaches resource-facing rows must remain a
|
||||
projection of `/api/connections` `fleet.commandPolicy`: desired server
|
||||
policy, applied agent truth, enforcement, and reason stay separate. Unified
|
||||
resource consumers may show compact remote-control status, but they must not
|
||||
treat top-level `remoteControl` as applied agent runtime truth, and they
|
||||
must preserve desired/applied drift or no-report attention when enriching
|
||||
resource rows.
|
||||
Platform-page stale-agent notices may consume canonical agent identity from
|
||||
merged resources only to scope the Infrastructure settings update-command
|
||||
route to the affected agents. That scoped lifecycle handoff must not become a
|
||||
new resource-action authority, a page-local command runner, or a substitute
|
||||
for the `/api/connections` fleet command-policy truth described above.
|
||||
Resource consumers must also use the API-owned agent update target when
|
||||
comparing resource-carried agent versions; the running app build version is
|
||||
not a resource freshness contract.
|
||||
Kubernetes node rows are cluster-agent-backed for this purpose: even when a
|
||||
canonical `k8s-node` row is a pure Kubernetes API projection with no merged
|
||||
`agent` facet, `internal/unifiedresources/adapters.go` must carry the
|
||||
cluster `AgentID` and cluster-scoped `AgentVersion` on the row's
|
||||
Kubernetes facet so platform consumers can scope stale-agent notices and
|
||||
update-command links from typed resource evidence instead of rebuilding
|
||||
ownership from the parent cluster row.
|
||||
`internal/unifiedresources/top_level_systems.go`
|
||||
Explicit linked-host correlation is canonical here: when Kubernetes node
|
||||
ingest has a resolved backing host agent, the registry must merge that node
|
||||
into the agent resource instead of publishing duplicate top-level
|
||||
infrastructure rows for the same machine under both `agent` and `k8s-node`
|
||||
identities.
|
||||
Canonical read-state overlays belong here as well: when monitoring or a
|
||||
preview path needs to project extra source-native records onto an existing
|
||||
settled read state, it must do so through
|
||||
`internal/unifiedresources/monitor_adapter.go` and
|
||||
`internal/unifiedresources/registry.go` so matcher seeding, manual links,
|
||||
and merge semantics stay unified-resource-owned instead of being rebuilt in
|
||||
consumers.
|
||||
Storage consumer projection is unified-resource-owned through
|
||||
`internal/unifiedresources/storage_consumers.go`. When a provider publishes
|
||||
source-native storage consumer metadata that cannot be derived from shared
|
||||
Proxmox/PBS relationship indexes, refresh must preserve that source-owned
|
||||
consumer count, consumer type list, and top-consumer summary on the
|
||||
canonical storage resource unless a stronger shared consumer projection has
|
||||
already populated those fields in the same refresh.
|
||||
Operator-facing storage posture wording is part of that same ownership:
|
||||
when multiple storage-risk reasons exist, shared posture helpers must prefer
|
||||
the most decision-useful protection loss summary such as lost parity over a
|
||||
generic disk-count aggregate, so resource drawers and incidents do not hide
|
||||
the actual protection boundary behind a broader count phrase. 6. Add canonical governed name-resolution or policy-aware resource lookup behavior through `internal/unifiedresources/resolve.go` and `internal/unifiedresources/resolve_context.go` 8. Add or change discovery-support runtime under the resource drawer through `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` for shell/presentation ownership, `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts` for fetch, websocket-progress, manual-run triggering, and notes-mutation ownership, and `frontend-modern/src/components/Discovery/discoveryReadiness.ts` for the shared readiness verdict used by resource-drawer Discovery surfaces. Embedded drawers may expose the top-level run action through this shared Discovery tab, but they must still call the canonical discovery trigger state path instead of introducing drawer-local API mutations.
|
||||
Drawer-level feature availability belongs to
|
||||
`frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts`.
|
||||
It consumes the shared AI runtime settings store and fails closed until the
|
||||
runtime explicitly reports `discovery_enabled=true`. Resource, guest, node,
|
||||
and Docker host drawers must use that boundary for every Discovery tab,
|
||||
readiness badge, analysis reveal, identified-service suggestion, and
|
||||
passive discovery-record query. A disabled or unresolved feature must leave
|
||||
no Discovery mention in drawer chrome or content and must not start a
|
||||
drawer-local discovery read.
|
||||
Resource drawer secondary sections, action history, discovery run summaries,
|
||||
and other compact resource-detail cards may own their resource-specific
|
||||
labels, rows, filters, and actions, but the repeated bordered compact frame
|
||||
is a frontend-primitives boundary. `ResourceDetailDrawerOverviewTab.tsx`,
|
||||
`ResourceActionHistory.tsx`, and `DiscoveryTab.tsx` must compose
|
||||
`InfoCardFrame` for that shell instead of restoring local card-frame
|
||||
classes.
|
||||
Curated technical inventory follows the shared compact-row contract instead
|
||||
of the secondary-card contract. Docker-host drawers must project system,
|
||||
runtime, memory, storage, and telemetry facts through
|
||||
`TechnicalDetailsSection` with canonical `DetailSection[]` data, while the
|
||||
unified-resource drawer projects runtime, identity, container, tag, alias,
|
||||
and address facts through the same `DetailSectionTable` responsive
|
||||
presentation. Only genuinely large or interactive provider-support content stays
|
||||
lazy behind `TechnicalDetailsDisclosure`; technical inventory must not
|
||||
restore a local card mosaic or add a drawer-open fetch. 9. Keep dashboard and infrastructure freshness on the canonical unified-resource
|
||||
ownership path. `frontend-modern/src/stores/websocket.ts`,
|
||||
`frontend-modern/src/utils/resourceStateAdapters.ts`, and
|
||||
`frontend-modern/src/hooks/useUnifiedResources.ts` together own the frontend
|
||||
canonicalization boundary: REST may hydrate the initial snapshot and
|
||||
unsupported filtered queries, but supported snapshot freshness must come
|
||||
from websocket `state.resources` instead of layering confirmatory
|
||||
route-local REST refetch loops over already-owned resource
|
||||
updates.
|
||||
Oversized WebSocket recovery is the transport exception: a complete
|
||||
`/api/state` response may refresh display state while the connection remains
|
||||
baseline-free, but resource deltas must not patch that independently built
|
||||
REST snapshot. The store resumes delta application only after the same
|
||||
connection delivers a complete WebSocket resource snapshot.
|
||||
Browser WebSocket liveness tracking is part of that same store boundary:
|
||||
valid inbound server messages, including heartbeat `ping`/`pong` traffic,
|
||||
must refresh the browser-side activity timestamp so quiet periods between
|
||||
resource snapshots do not cause avoidable reconnect churn.
|
||||
That shared store/adapter/hook path must also preserve canonical row shape
|
||||
across transport boundaries: thinner realtime `state.resources` payloads
|
||||
must merge into the existing canonical resource snapshot instead of
|
||||
downgrading richer REST-only infrastructure details such as disk I/O, source
|
||||
metadata, or platform summary fields after first hydrate. For default
|
||||
Source lists and their source-specific facets are the exception: a current
|
||||
snapshot with canonical source evidence replaces stale source lists and
|
||||
removes provider facets that no longer have matching source evidence, so
|
||||
rows do not keep displaying a previous platform identity after websocket
|
||||
refreshes. For default
|
||||
`initialHydration: 'immediate'` consumers, that same path must not paint the
|
||||
thinner websocket transport before the first canonical REST snapshot exists;
|
||||
only explicit websocket-first consumers may render directly from the realtime
|
||||
transport before canonical hydrate completes. Operator surfaces that must
|
||||
preserve already-known infrastructure continuity after login, such as the
|
||||
Infrastructure page, must use websocket-first hydration with stale-cache
|
||||
REST revalidation after the first-paint settle window so the page can paint
|
||||
from live state immediately without forcing a second resource-shape
|
||||
transition while summary and table surfaces are still mounting.
|
||||
Org-scope and enabled-state transitions in
|
||||
`frontend-modern/src/hooks/useUnifiedResources.ts` must invalidate older
|
||||
in-flight REST refreshes before publishing the new scoped cache entry, so a
|
||||
stale request cannot set active-scope errors, clear the active request guard,
|
||||
or replace the currently mounted Infrastructure/Workloads resource snapshot.
|
||||
Canonical cluster membership in that shared path must come only from
|
||||
explicit cluster identity such as Kubernetes context or platform cluster
|
||||
labels; standalone resource names must never be repurposed as synthetic
|
||||
`clusterId` values. 13. Keep operator-facing resource analysis vocabulary task-first on unified-resource
|
||||
surfaces. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx`,
|
||||
`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`,
|
||||
and `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` may expose
|
||||
provider identity or governed safe-summary posture when that context helps
|
||||
an operator, but the rendered labels must stay product-neutral and use
|
||||
`Analysis`, `Analysis Reasoning`, and `Safe Summary` rather than reviving
|
||||
generic `AI` or `AI-Safe` branding inside the resource drawer or discovery
|
||||
shell. 14. Keep the operator-facing unified resource table width-aware at the table
|
||||
surface, not just at the browser viewport. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx`
|
||||
must route its root ref through `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`,
|
||||
and `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
|
||||
owns the column-priority breakpoints for host and service infrastructure
|
||||
rows. When the app shell leaves tablet-sized space during live resize, the
|
||||
table hides lower-priority metadata first. At phone width, the state model
|
||||
must remove the old 640-pixel floor, preserve identity at exactly 30 percent
|
||||
of the table, and allocate the remaining width across the bounded
|
||||
source-relevant health and activity columns. Both the document and table
|
||||
shell must remain free of horizontal overflow; desktop and tablet stages
|
||||
retain their existing complete column contracts. 15. Keep shared policy-posture framing on the unified-resource card owner.
|
||||
`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
|
||||
may accept caller-owned subtitle or resource-count wording when Patrol or
|
||||
another shared surface needs to explain how the same governed policy counts
|
||||
should be read, but those framing lines must extend the shared card API
|
||||
rather than spawning page-local policy summary shells. 16. Keep platform/runtime top-level route paths on the canonical resource-link
|
||||
helper. `frontend-modern/src/routing/resourceLinks.ts` owns the
|
||||
`STANDALONE_PATH`, `DOCKER_PATH`, `KUBERNETES_PATH`, `TRUENAS_PATH`,
|
||||
`VMWARE_PATH`, `PATROL_PATH`, `PATROL_CONTROL_ANCHOR`,
|
||||
`PATROL_CONTROL_PATH`, and `PATROL_CONTROL_STARTER_QUERY_PARAM` constants,
|
||||
the route-backed Patrol control starter helpers, and the `buildStandalonePath`,
|
||||
`buildDockerPath`, `buildKubernetesPath`, `buildTrueNASPath`,
|
||||
`buildVmwarePath` builders.
|
||||
Per-platform surfaces and tab specs must
|
||||
derive every internal link from those builders so the canonical resource
|
||||
URL vocabulary stays single-sourced; ad hoc string concatenation of
|
||||
platform routes inside feature directories is not permitted. The canonical
|
||||
Pulse Intelligence external-agent hash
|
||||
`/settings/pulse-intelligence/assistant#external-agent-setup` and legacy
|
||||
`/settings/security/api#external-agent-setup` /
|
||||
`/settings/security/api#pulse-mcp-setup` compatibility hashes may live in
|
||||
the shared route helper, but they are adjacent settings route state, not
|
||||
unified-resource identity, platform scope, or drawer focus state.
|
||||
The Patrol `patrolControlStarter=patrol_control` query is an adjacent
|
||||
first-party Patrol control handoff flag, with legacy
|
||||
`operationsLoopStarter` values accepted only as compatibility aliases, not
|
||||
unified-resource filters or focus keys. Unified-resource consumers must not reuse those values for resource
|
||||
identity, list filtering, contextual focus, storage state, recovery state,
|
||||
or platform scoping.
|
||||
The user-facing Machines surface's default resource route is the machines projection
|
||||
(`/standalone/machines`); agentless endpoint rows use the
|
||||
`/standalone/availability` projection and must not be collapsed into a
|
||||
generic overview URL.
|
||||
The frontend-primitives-owned Machines IA contract consumes the
|
||||
unified-resource projection for Pulse-managed standalone agent rows and
|
||||
agentless availability endpoint rows; this subsystem owns only the
|
||||
membership rules for those projected rows. Agent membership must require
|
||||
`resource.type === "agent"`, canonical Pulse-agent source evidence from
|
||||
resource sources or source status, and no stronger provider-owner evidence
|
||||
from Proxmox, VMware, TrueNAS, or Kubernetes. Source-less legacy snapshots
|
||||
may fall back to a normalized `platformType === "agent"`, but
|
||||
provider-owned nodes must not become machine-page members through
|
||||
hostname, `agent` platform scope, or agent telemetry alone; those facts
|
||||
surface as facets on the owning provider page.
|
||||
`AgentsMachinesTable.tsx` may own row membership, resource-derived menu
|
||||
eligibility, evidence-gated column relevance, and remove-agent semantics
|
||||
for these projected rows. In particular, the GPU metric column and its
|
||||
View choice are relevant only when at least one projected machine reports
|
||||
finite GPU utilization; absent telemetry must not produce an empty default
|
||||
column or a no-op column choice, while persisted visibility remains ready
|
||||
for the column when evidence later appears. The
|
||||
compact row action trigger chrome stays under the frontend-primitives
|
||||
`ActionIconButton` boundary rather than becoming a unified-resource-local
|
||||
button shell.
|
||||
Machines list search and online-state narrowing are frontend route state,
|
||||
not new unified-resource membership fields. `StandalonePageSurface.tsx`
|
||||
owns the `STANDALONE_QUERY_PARAMS` query/status projection and one composite
|
||||
reset, while `AgentsMachinesTable.tsx` consumes those controlled values so
|
||||
saved links and bookmarks cannot diverge from the canonical projected row
|
||||
set. Those query parameters must only narrow the already-owned agent
|
||||
projection; they must not cause provider nodes or availability endpoints to
|
||||
enter the Machines membership bucket.
|
||||
`PROXMOX_BACKUPS_QUERY_PARAMS` in the same shared route-helper module is
|
||||
storage/recovery-owned filter and workspace state for the Proxmox Backups
|
||||
surface. Its query, view, node, type, source, posture, and day keys may
|
||||
narrow already-correlated backup rows, but they do not add unified-resource
|
||||
membership, change canonical workload identity, or turn recovery evidence
|
||||
into a provider-resource projection.
|
||||
The Proxmox backup workspace's chronological and coverage views are
|
||||
canonical route state under `/proxmox/backups/date` and
|
||||
`/proxmox/backups/coverage`. The shared route helper owns those path
|
||||
builders; the backup surface must use the shared platform section-tab
|
||||
primitive for navigation and may retain legacy query parsing only as
|
||||
compatibility input. Those view paths describe backup evidence and must
|
||||
not be treated as unified-resource membership or workload identity.
|
||||
The default tab for each platform path must point at a sub-tab whose
|
||||
canonical unified-resource projection actually populates, and visible
|
||||
workflow subtabs must stay evidence-gated by the same canonical row or
|
||||
signal source instead of advertising empty object browsers. The
|
||||
canonical TrueNAS adapter (`internal/truenas/provider.go::
|
||||
truenasRecordsFromSnapshot`) already emits the top-level TrueNAS
|
||||
appliance as a unified `agent` row tagged with the `truenas`
|
||||
platform, so TrueNAS defaults to `/truenas/overview` (the Systems
|
||||
sub-tab); the embedded `StorageSurface` lives at `/truenas/storage`.
|
||||
Any future platform that wants to default to a Systems / Hosts
|
||||
overview must first have its canonical resource adapter project the
|
||||
platform's top-level system as a unified resource so the builder
|
||||
default still resolves to a populated table.
|
||||
appliance as a unified `agent` row tagged with the `truenas`
|
||||
platform, so TrueNAS defaults to `/truenas/overview` (the Systems
|
||||
sub-tab); the embedded `StorageSurface` lives at `/truenas/storage`.
|
||||
Any future platform that wants to default to a Systems / Hosts
|
||||
overview must first have its canonical resource adapter project the
|
||||
platform's top-level system as a unified resource so the builder
|
||||
default still resolves to a populated table.
|
||||
|
||||
17. Platform table ordering is a two-layer contract. Each platform table's
|
||||
default order is owned by its page model's status-first compare
|
||||
@@ -1613,7 +1605,7 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and
|
||||
the row may highlight in place through the shared active-resource id; if it
|
||||
is off-screen, the page must offer an explicit `Jump to row` affordance
|
||||
rather than auto-scrolling or collapsing the table on hover.
|
||||
12a. Keep infrastructure summary visibility as display preference, not a
|
||||
12a. Keep infrastructure summary visibility as display preference, not a
|
||||
unified-resource filter. Platform/runtime pages and shared infrastructure
|
||||
summary consumers may hide or restore chart sections through shared
|
||||
presentation controls, but those controls must not mutate resource
|
||||
@@ -2025,7 +2017,6 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and
|
||||
`internal/recovery/store/store_test.go`
|
||||
(`TestStore_OpenBackfillsLegacyUnresolvedProxmoxPBSGuestRows`).
|
||||
|
||||
|
||||
## Current State
|
||||
|
||||
### Agent libvirt domains use a provider-neutral VM facet
|
||||
@@ -2199,7 +2190,9 @@ rendering or frontend fallback code.
|
||||
`resource_operator_state.go` owns the operator-set per-resource intent
|
||||
schema. `ResourceOperatorState` carries five narrow operator-intent
|
||||
fields (`IntentionallyOffline`, `NeverAutoRemediate`, maintenance
|
||||
window via `MaintenanceStartAt` / `MaintenanceEndAt` / `MaintenanceReason`,
|
||||
window via one-shot `MaintenanceStartAt` / `MaintenanceEndAt` or a mutually
|
||||
exclusive weekly `MaintenanceRecurrence`, shared `MaintenanceReason`, and
|
||||
explicit `MaintenanceScope`,
|
||||
canonical `Criticality` hint of `high|medium|low|""`, and an explicit
|
||||
`AutoRemediationPolicy`) plus
|
||||
operator-attribution metadata (`Note`, `SetAt`, `SetBy`). The shape is
|
||||
@@ -2219,6 +2212,18 @@ is the explicit per-resource opt-out and always wins. Capability-owned
|
||||
capability is never eligible, low-risk eligible, or elevated eligible; it does
|
||||
not lower `MinimumApprovalLevel`. The first eligible vertical is Docker/Podman
|
||||
container `restart` at `low_risk`; unspecified capabilities normalize to never.
|
||||
Recurring maintenance names the local weekdays on which occurrences start,
|
||||
uses inclusive start and exclusive end minutes, may cross midnight, and is
|
||||
evaluated in its stored IANA timezone so DST and server relocation cannot
|
||||
silently move operator intent. Weekdays normalize to unique Monday-through-
|
||||
Sunday order. `maintenanceScope` defaults to `resource`; the only inherited
|
||||
value is `resource_and_descendants`, resolved against the canonical registry
|
||||
parent chain with cycle protection. Concrete occurrence start/end boundaries
|
||||
are derived by the same model for Alerts and post-window verification. SQLite
|
||||
persists recurrence JSON and scope in additive columns while legacy one-shot
|
||||
rows retain their existing meaning. Scheduling, updating, changing scope, or
|
||||
clearing either form remains an atomic operator-state plus resource-timeline
|
||||
lifecycle write.
|
||||
`NormalizeResourceOperatorState` trims whitespace, de-duplicates capability
|
||||
names, guarantees a non-nil empty capability list, and lower-cases the
|
||||
criticality value before persistence. SQLite reads must apply that
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d48e70da152525471c1d1ee3364c92c76b4d06af",
|
||||
"verified_at": "2026-08-27T17:00:26Z",
|
||||
"base_sha": "ce555ee09d7cef30a2d784c18c629272aef9ab22",
|
||||
"verified_at": "2026-08-27T17:11:50Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
|
||||
"frontend-modern/src/components/shared/detailSectionModel.ts"
|
||||
"frontend-modern/src/api/resourceOperatorState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx",
|
||||
"frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "438dd4e834e576911bcf06d8849f832bb59e9349ff6476a3b437b356ecdf3d1a",
|
||||
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "5b2592463049e3b1740fa8c63cabb09529a83bd1cd64509c57b473d3b05dc35a",
|
||||
"frontend-modern/src/components/shared/detailSectionModel.ts": "da27583092cad09d50ea03c4308f7841bd2475e8c6539527142da06a78d9722f"
|
||||
"frontend-modern/src/api/resourceOperatorState.ts": "edbbb0253d63fba3f8c893edb4c911948883c88ba1ee61ecdac453853d370de4",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "9b45eb2a826c6db7fb7a47ec3dcc341c1ed47109228d83ed085788e647c69012",
|
||||
"frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx": "fc2e4d010d80419e9532c49cddd9df954672c2d4a3b5de9d3336fd483562a4ad"
|
||||
},
|
||||
"routes": ["/proxmox/overview"],
|
||||
"routes": ["/alerts/overview", "/proxmox/overview"],
|
||||
"viewports": [
|
||||
{ "width": 1920, "height": 800 },
|
||||
{ "width": 1280, "height": 800 },
|
||||
{ "width": 390, "height": 844 }
|
||||
],
|
||||
"states": [
|
||||
"expanded backup-vault backup-server resource drawer with Overview selected",
|
||||
"runtime context and identity sections with rich alias values"
|
||||
"active Alerts overview with the per-alert Monitoring policy action",
|
||||
"newly discovered resource with no explicit operator-state record",
|
||||
"resource Manage drawer with the maintenance scheduler open in recurring mode",
|
||||
"recurring schedule with weekday, Europe/London timezone, and descendant scope selected"
|
||||
],
|
||||
"interactions": [
|
||||
"expanded the first backup-vault row and confirmed two aligned equal-height desktop cards with a local 7rem label column",
|
||||
"confirmed the same sections retain compact native table rows at 390x844 with zero document horizontal overflow",
|
||||
"reloaded the final implementation and confirmed no new browser console errors"
|
||||
"opened an alert Monitoring action and confirmed the canonical policy choices render without changing saved state",
|
||||
"opened a resource with no saved operator state and confirmed the lookup completes without a failed response or console error",
|
||||
"opened Manage, switched the maintenance scheduler from One time to Recurring, and exercised weekday, timezone, and descendant-scope controls without saving",
|
||||
"verified the full scheduler at 1280x800 and 390x844 with no horizontal overflow, interaction console errors, or failed interaction responses"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,12 +33,18 @@ describe('resourceOperatorState api', () => {
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
// colons are reserved in URL paths and must be percent-encoded
|
||||
// before the canonical id reaches the server router.
|
||||
'/api/resources/instance%3Anode%3A101/operator-state',
|
||||
'/api/resources/instance%3Anode%3A101/operator-state?view=lookup',
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the server reports operator_state_not_set as 404', async () => {
|
||||
it('returns null from the successful lookup envelope when no state is configured', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ configured: false });
|
||||
|
||||
await expect(getResourceOperatorState('vm:101')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when an older server reports operator_state_not_set as 404', async () => {
|
||||
apiFetchJSONMock.mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 }));
|
||||
|
||||
await expect(getResourceOperatorState('vm:101')).resolves.toBeNull();
|
||||
|
||||
@@ -10,6 +10,14 @@ import { apiFetchJSON } from '@/utils/apiClient';
|
||||
export type ResourceCriticality = 'high' | 'medium' | 'low' | '';
|
||||
export type ResourceMonitoringMode = 'normal' | 'expected_offline' | 'muted';
|
||||
export type ResourceLifecycleState = 'active' | 'retired';
|
||||
export type MaintenanceScope = 'resource' | 'resource_and_descendants';
|
||||
|
||||
export interface RecurringMaintenanceWindow {
|
||||
timezone: string;
|
||||
weekdays: string[];
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
}
|
||||
|
||||
export interface AutoRemediationWindow {
|
||||
timezone: string;
|
||||
@@ -57,7 +65,12 @@ export interface ResourceOperatorState {
|
||||
*/
|
||||
maintenanceStartAt?: string;
|
||||
maintenanceEndAt?: string;
|
||||
maintenanceRecurrence?: RecurringMaintenanceWindow;
|
||||
maintenanceScope?: MaintenanceScope;
|
||||
maintenanceReason?: string;
|
||||
maintenanceWindowActive?: boolean;
|
||||
maintenanceActiveStartAt?: string;
|
||||
maintenanceActiveEndAt?: string;
|
||||
/**
|
||||
* Optional operator hint that affects finding sort order. One of
|
||||
* `'high' | 'medium' | 'low' | ''` (empty = default).
|
||||
@@ -68,6 +81,11 @@ export interface ResourceOperatorState {
|
||||
setBy?: string;
|
||||
}
|
||||
|
||||
interface ResourceOperatorStateLookup {
|
||||
configured: boolean;
|
||||
state?: ResourceOperatorState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PUT body shape — same as the read model but with attribution
|
||||
* stripped because the server populates `setAt` and `setBy` from the
|
||||
@@ -75,7 +93,12 @@ export interface ResourceOperatorState {
|
||||
*/
|
||||
export type ResourceOperatorStateInput = Omit<
|
||||
ResourceOperatorState,
|
||||
'canonicalId' | 'setAt' | 'setBy'
|
||||
| 'canonicalId'
|
||||
| 'setAt'
|
||||
| 'setBy'
|
||||
| 'maintenanceWindowActive'
|
||||
| 'maintenanceActiveStartAt'
|
||||
| 'maintenanceActiveEndAt'
|
||||
>;
|
||||
|
||||
const normalizeResourceOperatorState = (state: ResourceOperatorState): ResourceOperatorState => ({
|
||||
@@ -83,26 +106,33 @@ const normalizeResourceOperatorState = (state: ResourceOperatorState): ResourceO
|
||||
monitoringMode:
|
||||
state.monitoringMode || (state.intentionallyOffline ? 'expected_offline' : 'normal'),
|
||||
lifecycleState: state.lifecycleState || 'active',
|
||||
maintenanceScope: state.maintenanceScope || 'resource',
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the operator-set state for a resource. Resolves to null when
|
||||
* the server returns 404 (no entry recorded — the default no-state
|
||||
* posture). Throws on other errors.
|
||||
* Read the operator-set state for a resource. The lookup view represents an
|
||||
* unset record as a successful envelope so opening a newly discovered
|
||||
* resource does not generate a routine 404 in the browser. The 404 fallback
|
||||
* keeps the frontend compatible with an older server during a rolling update.
|
||||
*/
|
||||
export async function getResourceOperatorState(
|
||||
resourceId: string,
|
||||
): Promise<ResourceOperatorState | null> {
|
||||
try {
|
||||
const state = await apiFetchJSON<ResourceOperatorState>(
|
||||
`/api/resources/${encodeURIComponent(resourceId)}/operator-state`,
|
||||
const result = await apiFetchJSON<ResourceOperatorStateLookup | ResourceOperatorState>(
|
||||
`/api/resources/${encodeURIComponent(resourceId)}/operator-state?view=lookup`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
return normalizeResourceOperatorState(state);
|
||||
if ('configured' in result) {
|
||||
if (!result.configured || !result.state) return null;
|
||||
return normalizeResourceOperatorState(result.state);
|
||||
}
|
||||
// Rolling-update compatibility: a previous server returns the persisted
|
||||
// state directly when it exists.
|
||||
return normalizeResourceOperatorState(result);
|
||||
} catch (err) {
|
||||
// The 404 response shape is `{ error: 'operator_state_not_set', ... }`.
|
||||
// Translating into null lets the caller treat "no state" as a clean
|
||||
// default rather than a thrown error.
|
||||
// Previous servers express no saved state as 404
|
||||
// `{ error: 'operator_state_not_set', ... }`.
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
|
||||
@@ -115,10 +115,38 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
// (UTC offset preserved) — formatLocalForInput / parseLocalFromInput
|
||||
// handle the conversion.
|
||||
const [schedulerOpen, setSchedulerOpen] = createSignal(false);
|
||||
const [scheduleKind, setScheduleKind] = createSignal<'once' | 'recurring'>('once');
|
||||
const [scheduleStart, setScheduleStart] = createSignal('');
|
||||
const [scheduleEnd, setScheduleEnd] = createSignal('');
|
||||
const [scheduleWeekdays, setScheduleWeekdays] = createSignal<string[]>([
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
const [scheduleRecurringStart, setScheduleRecurringStart] = createSignal('02:00');
|
||||
const [scheduleRecurringEnd, setScheduleRecurringEnd] = createSignal('03:00');
|
||||
const [scheduleTimezone, setScheduleTimezone] = createSignal(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
|
||||
);
|
||||
const [scheduleScope, setScheduleScope] = createSignal<'resource' | 'resource_and_descendants'>(
|
||||
'resource',
|
||||
);
|
||||
const [scheduleReason, setScheduleReason] = createSignal('');
|
||||
|
||||
const maintenanceWeekdays = [
|
||||
['monday', 'Mon'],
|
||||
['tuesday', 'Tue'],
|
||||
['wednesday', 'Wed'],
|
||||
['thursday', 'Thu'],
|
||||
['friday', 'Fri'],
|
||||
['saturday', 'Sat'],
|
||||
['sunday', 'Sun'],
|
||||
] as const;
|
||||
|
||||
// Hydrate edit state from persisted record on first load and on resource change.
|
||||
createEffect(() => {
|
||||
const current = persisted();
|
||||
@@ -225,6 +253,8 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
// on save would surprise the operator.
|
||||
maintenanceStartAt: current?.maintenanceStartAt,
|
||||
maintenanceEndAt: current?.maintenanceEndAt,
|
||||
maintenanceRecurrence: current?.maintenanceRecurrence,
|
||||
maintenanceScope: current?.maintenanceScope ?? 'resource',
|
||||
maintenanceReason: current?.maintenanceReason,
|
||||
criticality: criticality(),
|
||||
note: noteForSave(),
|
||||
@@ -310,6 +340,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
// active and future windows are surfaced, with different copy.
|
||||
const activeMaintenanceWindow = createMemo(() => {
|
||||
const current = persisted();
|
||||
if (current?.maintenanceWindowActive && current.maintenanceActiveEndAt) return current;
|
||||
if (!current?.maintenanceStartAt || !current?.maintenanceEndAt) return null;
|
||||
const now = Date.now();
|
||||
const start = Date.parse(current.maintenanceStartAt);
|
||||
@@ -333,7 +364,10 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
});
|
||||
|
||||
const hasAnyMaintenanceWindow = createMemo(() =>
|
||||
Boolean(activeMaintenanceWindow() || scheduledMaintenanceWindow()),
|
||||
Boolean(
|
||||
persisted()?.maintenanceRecurrence ||
|
||||
(persisted()?.maintenanceStartAt && persisted()?.maintenanceEndAt),
|
||||
),
|
||||
);
|
||||
|
||||
// Datetime-local input format is "YYYY-MM-DDTHH:mm" in the browser's
|
||||
@@ -357,13 +391,23 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
// Pre-fill from the persisted window when one exists; otherwise
|
||||
// default to "starting now, ending in one hour" — the most common
|
||||
// shape for a quick maintenance.
|
||||
if (current?.maintenanceStartAt && current?.maintenanceEndAt) {
|
||||
setScheduleScope(current?.maintenanceScope ?? 'resource');
|
||||
if (current?.maintenanceRecurrence) {
|
||||
setScheduleKind('recurring');
|
||||
setScheduleWeekdays(current.maintenanceRecurrence.weekdays);
|
||||
setScheduleRecurringStart(minuteToTime(current.maintenanceRecurrence.startMinute));
|
||||
setScheduleRecurringEnd(minuteToTime(current.maintenanceRecurrence.endMinute));
|
||||
setScheduleTimezone(current.maintenanceRecurrence.timezone);
|
||||
setScheduleReason(current.maintenanceReason ?? '');
|
||||
} else if (current?.maintenanceStartAt && current?.maintenanceEndAt) {
|
||||
setScheduleKind('once');
|
||||
const start = new Date(current.maintenanceStartAt);
|
||||
const end = new Date(current.maintenanceEndAt);
|
||||
if (!Number.isNaN(start.getTime())) setScheduleStart(formatLocalForInput(start));
|
||||
if (!Number.isNaN(end.getTime())) setScheduleEnd(formatLocalForInput(end));
|
||||
setScheduleReason(current.maintenanceReason ?? '');
|
||||
} else {
|
||||
setScheduleKind('once');
|
||||
setScheduleStart(formatLocalForInput(now));
|
||||
setScheduleEnd(formatLocalForInput(oneHourFromNow));
|
||||
setScheduleReason('');
|
||||
@@ -377,7 +421,23 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
setScheduleEnd(formatLocalForInput(end));
|
||||
};
|
||||
|
||||
const toggleMaintenanceWeekday = (weekday: string) => {
|
||||
setScheduleWeekdays((current) =>
|
||||
current.includes(weekday)
|
||||
? current.filter((candidate) => candidate !== weekday)
|
||||
: [...current, weekday],
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleValidationError = createMemo(() => {
|
||||
if (scheduleKind() === 'recurring') {
|
||||
if (scheduleWeekdays().length === 0) return 'Select at least one day.';
|
||||
if (!scheduleTimezone().trim()) return 'Timezone is required.';
|
||||
if (scheduleRecurringStart() === scheduleRecurringEnd()) {
|
||||
return 'Recurring start and end times must differ.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const start = parseLocalFromInput(scheduleStart());
|
||||
const end = parseLocalFromInput(scheduleEnd());
|
||||
if (!start || !end) return 'Both start and end are required.';
|
||||
@@ -388,11 +448,11 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
const handleScheduleSave = async () => {
|
||||
const start = parseLocalFromInput(scheduleStart());
|
||||
const end = parseLocalFromInput(scheduleEnd());
|
||||
if (!start || !end) {
|
||||
if (scheduleKind() === 'once' && (!start || !end)) {
|
||||
notificationStore.error('Both start and end are required.');
|
||||
return;
|
||||
}
|
||||
if (end.getTime() <= start.getTime()) {
|
||||
if (scheduleKind() === 'once' && end!.getTime() <= start!.getTime()) {
|
||||
notificationStore.error('Maintenance end must be strictly after start.');
|
||||
return;
|
||||
}
|
||||
@@ -418,8 +478,18 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
maintenanceStartAt: start.toISOString(),
|
||||
maintenanceEndAt: end.toISOString(),
|
||||
maintenanceStartAt: scheduleKind() === 'once' ? start!.toISOString() : undefined,
|
||||
maintenanceEndAt: scheduleKind() === 'once' ? end!.toISOString() : undefined,
|
||||
maintenanceRecurrence:
|
||||
scheduleKind() === 'recurring'
|
||||
? {
|
||||
timezone: scheduleTimezone().trim(),
|
||||
weekdays: scheduleWeekdays(),
|
||||
startMinute: timeToMinute(scheduleRecurringStart()),
|
||||
endMinute: timeToMinute(scheduleRecurringEnd()),
|
||||
}
|
||||
: undefined,
|
||||
maintenanceScope: scheduleScope(),
|
||||
maintenanceReason: scheduleReason().trim() || undefined,
|
||||
criticality: criticality(),
|
||||
note: noteForSave(),
|
||||
@@ -460,6 +530,8 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
},
|
||||
maintenanceStartAt: undefined,
|
||||
maintenanceEndAt: undefined,
|
||||
maintenanceRecurrence: undefined,
|
||||
maintenanceScope: 'resource',
|
||||
maintenanceReason: undefined,
|
||||
criticality: criticality(),
|
||||
note: noteForSave(),
|
||||
@@ -505,8 +577,18 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
<Show when={activeMaintenanceWindow()}>
|
||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||
<span class="font-semibold">Maintenance window active.</span> Findings raised on this
|
||||
resource are auto-acknowledged until{' '}
|
||||
{formatRelativeTime(activeMaintenanceWindow()!.maintenanceEndAt!, { compact: true })}.
|
||||
resource
|
||||
<Show when={activeMaintenanceWindow()!.maintenanceScope === 'resource_and_descendants'}>
|
||||
{' '}
|
||||
and its descendants
|
||||
</Show>{' '}
|
||||
are auto-acknowledged until{' '}
|
||||
{formatRelativeTime(
|
||||
activeMaintenanceWindow()!.maintenanceActiveEndAt ??
|
||||
activeMaintenanceWindow()!.maintenanceEndAt!,
|
||||
{ compact: true },
|
||||
)}
|
||||
.
|
||||
<Show when={activeMaintenanceWindow()!.maintenanceReason}>
|
||||
<span class="block mt-0.5">Reason: {activeMaintenanceWindow()!.maintenanceReason}</span>
|
||||
</Show>
|
||||
@@ -528,6 +610,26 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={persisted()?.maintenanceRecurrence && !activeMaintenanceWindow()}>
|
||||
<div class="rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<span class="font-semibold">Recurring maintenance configured.</span>{' '}
|
||||
{persisted()!
|
||||
.maintenanceRecurrence!.weekdays.map((weekday) => weekday.slice(0, 3))
|
||||
.join(', ')}{' '}
|
||||
from {minuteToTime(persisted()!.maintenanceRecurrence!.startMinute)} to{' '}
|
||||
{minuteToTime(persisted()!.maintenanceRecurrence!.endMinute)}{' '}
|
||||
{persisted()!.maintenanceRecurrence!.timezone}.
|
||||
<Show when={persisted()?.maintenanceScope === 'resource_and_descendants'}>
|
||||
<span class="block mt-0.5 font-medium">
|
||||
This resource and all canonical descendants are covered.
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={persisted()!.maintenanceReason}>
|
||||
<span class="block mt-0.5">Reason: {persisted()!.maintenanceReason}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 pt-2 border-t border-border-subtle sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
|
||||
<FormSelect
|
||||
label="Patrol priority"
|
||||
@@ -605,56 +707,130 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
<Show when={schedulerOpen()}>
|
||||
<div class="rounded border border-border bg-surface-alt/40 px-3 py-3 space-y-2">
|
||||
<div class="text-xs font-semibold text-base-content">Schedule maintenance window</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">Start</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduleStart()}
|
||||
onInput={(e) => setScheduleStart(e.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full text-xs rounded border border-border bg-surface px-2 py-1 text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 sm:min-h-0"
|
||||
disabled={saving()}
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">End</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduleEnd()}
|
||||
onInput={(e) => setScheduleEnd(e.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full text-xs rounded border border-border bg-surface px-2 py-1 text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 sm:min-h-0"
|
||||
disabled={saving()}
|
||||
/>
|
||||
</label>
|
||||
<div class="inline-flex gap-0.5 rounded border border-border bg-surface p-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduleKind('once')}
|
||||
class={`rounded px-2.5 py-1 ${scheduleKind() === 'once' ? 'bg-blue-600 text-white' : 'text-muted hover:bg-surface-hover'}`}
|
||||
>
|
||||
One time
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduleKind('recurring')}
|
||||
class={`rounded px-2.5 py-1 ${scheduleKind() === 'recurring' ? 'bg-blue-600 text-white' : 'text-muted hover:bg-surface-hover'}`}
|
||||
>
|
||||
Recurring
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-[11px]">
|
||||
<span class="text-muted">Quick presets:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPresetDuration(1)}
|
||||
disabled={saving()}
|
||||
class="min-h-11 min-w-11 px-1.5 py-0.5 rounded border border-border hover:bg-surface-hover disabled:opacity-50 sm:min-h-0 sm:min-w-0"
|
||||
>
|
||||
1h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPresetDuration(4)}
|
||||
disabled={saving()}
|
||||
class="min-h-11 min-w-11 px-1.5 py-0.5 rounded border border-border hover:bg-surface-hover disabled:opacity-50 sm:min-h-0 sm:min-w-0"
|
||||
>
|
||||
4h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPresetDuration(24)}
|
||||
disabled={saving()}
|
||||
class="min-h-11 min-w-11 px-1.5 py-0.5 rounded border border-border hover:bg-surface-hover disabled:opacity-50 sm:min-h-0 sm:min-w-0"
|
||||
>
|
||||
24h
|
||||
</button>
|
||||
</div>
|
||||
<Show when={scheduleKind() === 'once'}>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">Start</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduleStart()}
|
||||
onInput={(e) => setScheduleStart(e.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full text-xs rounded border border-border bg-surface px-2 py-1 text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 sm:min-h-0"
|
||||
disabled={saving()}
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">End</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduleEnd()}
|
||||
onInput={(e) => setScheduleEnd(e.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full text-xs rounded border border-border bg-surface px-2 py-1 text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 sm:min-h-0"
|
||||
disabled={saving()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[11px]">
|
||||
<span class="text-muted">Quick presets:</span>
|
||||
<For each={[1, 4, 24]}>
|
||||
{(hours) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPresetDuration(hours)}
|
||||
disabled={saving()}
|
||||
class="min-h-11 min-w-11 px-1.5 py-0.5 rounded border border-border hover:bg-surface-hover disabled:opacity-50 sm:min-h-0 sm:min-w-0"
|
||||
>
|
||||
{hours}h
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={scheduleKind() === 'recurring'}>
|
||||
<div class="space-y-2">
|
||||
<fieldset>
|
||||
<legend class="text-[11px] text-muted">Days the window starts</legend>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<For each={maintenanceWeekdays}>
|
||||
{([value, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={scheduleWeekdays().includes(value)}
|
||||
onClick={() => toggleMaintenanceWeekday(value)}
|
||||
class={`min-h-9 rounded border px-2 text-xs ${scheduleWeekdays().includes(value) ? 'border-blue-600 bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-200' : 'border-border text-muted hover:bg-surface-hover'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">Start</span>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleRecurringStart()}
|
||||
onInput={(event) => setScheduleRecurringStart(event.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full rounded border border-border bg-surface px-2 text-xs text-base-content sm:min-h-0"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">End</span>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleRecurringEnd()}
|
||||
onInput={(event) => setScheduleRecurringEnd(event.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full rounded border border-border bg-surface px-2 text-xs text-base-content sm:min-h-0"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">Timezone</span>
|
||||
<input
|
||||
type="text"
|
||||
value={scheduleTimezone()}
|
||||
onInput={(event) => setScheduleTimezone(event.currentTarget.value)}
|
||||
class="mt-0.5 min-h-11 w-full rounded border border-border bg-surface px-2 text-xs text-base-content sm:min-h-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted">
|
||||
End times earlier than start times continue into the following day.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<FormSelect
|
||||
label="Applies to"
|
||||
density="compact"
|
||||
value={scheduleScope()}
|
||||
onChange={(event) =>
|
||||
setScheduleScope(event.currentTarget.value as 'resource' | 'resource_and_descendants')
|
||||
}
|
||||
help="Descendant scope follows Pulse's canonical inventory hierarchy and covers resources added beneath this one later."
|
||||
helpClass="text-[11px] leading-tight"
|
||||
>
|
||||
<option value="resource">This resource only</option>
|
||||
<option value="resource_and_descendants">This resource and descendants</option>
|
||||
</FormSelect>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-[11px] text-muted">Reason (optional)</span>
|
||||
|
||||
+20
-5
@@ -84,6 +84,8 @@ describe('ResourceOperatorStateSection', () => {
|
||||
// local edit state.
|
||||
expect(sectionSource).toContain('maintenanceStartAt: current?.maintenanceStartAt');
|
||||
expect(sectionSource).toContain('maintenanceEndAt: current?.maintenanceEndAt');
|
||||
expect(sectionSource).toContain('maintenanceRecurrence: current?.maintenanceRecurrence');
|
||||
expect(sectionSource).toContain("maintenanceScope: current?.maintenanceScope ?? 'resource'");
|
||||
expect(sectionSource).toContain('maintenanceReason: current?.maintenanceReason');
|
||||
expect(sectionSource).toContain('criticality: criticality()');
|
||||
expect(sectionSource).toContain('note: noteForSave()');
|
||||
@@ -109,8 +111,10 @@ describe('ResourceOperatorStateSection', () => {
|
||||
// must forward the local priority/note signals rather than the last
|
||||
// persisted values. Otherwise editing a note and then scheduling a
|
||||
// window would silently lose the note.
|
||||
expect(sectionSource).toContain('maintenanceStartAt: start.toISOString()');
|
||||
expect(sectionSource).toContain('maintenanceEndAt: end.toISOString()');
|
||||
expect(sectionSource).toContain("scheduleKind() === 'once' ? start!.toISOString() : undefined");
|
||||
expect(sectionSource).toContain("scheduleKind() === 'recurring'");
|
||||
expect(sectionSource).toContain('maintenanceRecurrence:');
|
||||
expect(sectionSource).toContain('maintenanceScope: scheduleScope()');
|
||||
expect(sectionSource).toContain('criticality: criticality()');
|
||||
expect(sectionSource).toContain('note: noteForSave()');
|
||||
expect(sectionSource).not.toContain('criticality: current?.criticality');
|
||||
@@ -129,9 +133,8 @@ describe('ResourceOperatorStateSection', () => {
|
||||
expect(sectionSource).toContain('type="datetime-local"');
|
||||
expect(sectionSource).toContain('scheduleValidationError');
|
||||
// Quick presets — the three most common operator durations.
|
||||
expect(sectionSource).toContain('applyPresetDuration(1)');
|
||||
expect(sectionSource).toContain('applyPresetDuration(4)');
|
||||
expect(sectionSource).toContain('applyPresetDuration(24)');
|
||||
expect(sectionSource).toContain('<For each={[1, 4, 24]}>');
|
||||
expect(sectionSource).toContain('applyPresetDuration(hours)');
|
||||
// Both directions of the datetime conversion live in helpers so the
|
||||
// scheduler stays free of inline date arithmetic.
|
||||
expect(sectionSource).toContain('formatLocalForInput');
|
||||
@@ -176,6 +179,18 @@ describe('ResourceOperatorStateSection', () => {
|
||||
expect(sectionSource).toContain('neverAutoRemediate: neverAutoRemediate()');
|
||||
expect(sectionSource).toContain('maintenanceStartAt: undefined,');
|
||||
expect(sectionSource).toContain('maintenanceEndAt: undefined,');
|
||||
expect(sectionSource).toContain('maintenanceRecurrence: undefined,');
|
||||
expect(sectionSource).toContain("maintenanceScope: 'resource',");
|
||||
});
|
||||
|
||||
it('offers recurring timezone-aware windows and explicit descendant scope', () => {
|
||||
expect(sectionSource).toContain("createSignal<'once' | 'recurring'>");
|
||||
expect(sectionSource).toContain('Days the window starts');
|
||||
expect(sectionSource).toContain('scheduleTimezone');
|
||||
expect(sectionSource).toContain('startMinute: timeToMinute(scheduleRecurringStart())');
|
||||
expect(sectionSource).toContain('endMinute: timeToMinute(scheduleRecurringEnd())');
|
||||
expect(sectionSource).toContain('resource_and_descendants');
|
||||
expect(sectionSource).toContain('Recurring maintenance configured.');
|
||||
});
|
||||
|
||||
it('keeps drawer disclosure and operator controls touch-safe on phones', () => {
|
||||
|
||||
@@ -119,6 +119,8 @@ export function ResourceMonitoringPolicyAction(props: ResourceMonitoringPolicyAc
|
||||
},
|
||||
maintenanceStartAt: current?.maintenanceStartAt,
|
||||
maintenanceEndAt: current?.maintenanceEndAt,
|
||||
maintenanceRecurrence: current?.maintenanceRecurrence,
|
||||
maintenanceScope: current?.maintenanceScope ?? 'resource',
|
||||
maintenanceReason: current?.maintenanceReason,
|
||||
criticality: current?.criticality ?? '',
|
||||
note: current?.note,
|
||||
|
||||
@@ -32,6 +32,7 @@ import alertOverviewActiveAlertsSectionSource from '@/features/alerts/AlertOverv
|
||||
import alertOverviewAlertCardSource from '@/features/alerts/AlertOverviewAlertCard.tsx?raw';
|
||||
import alertOverviewStatsCardsSource from '@/features/alerts/AlertOverviewStatsCards.tsx?raw';
|
||||
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
|
||||
import resourceMonitoringPolicyActionSource from '@/features/alerts/ResourceMonitoringPolicyAction.tsx?raw';
|
||||
import alertScheduleStateSource from '@/features/alerts/useAlertScheduleState.ts?raw';
|
||||
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
|
||||
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
|
||||
@@ -1172,6 +1173,15 @@ describe('unifiedTypeToAlertDisplayType', () => {
|
||||
});
|
||||
|
||||
describe('Unified selector parity', () => {
|
||||
it('preserves recurring maintenance and descendant scope when an alert changes monitoring policy', () => {
|
||||
expect(resourceMonitoringPolicyActionSource).toContain(
|
||||
'maintenanceRecurrence: current?.maintenanceRecurrence',
|
||||
);
|
||||
expect(resourceMonitoringPolicyActionSource).toContain(
|
||||
"maintenanceScope: current?.maintenanceScope ?? 'resource'",
|
||||
);
|
||||
});
|
||||
|
||||
it('maps all unified resource types to display types', () => {
|
||||
const cases: Array<[ResourceType, string]> = [
|
||||
['agent', 'Agent'],
|
||||
|
||||
@@ -124,10 +124,15 @@ func operatorStateOutputSchema() json.RawMessage {
|
||||
"type": "string", "enum": []string{"active", "retired"},
|
||||
"description": "Whether the resource remains operationally active in Pulse.",
|
||||
},
|
||||
"intentionallyOffline": booleanOption("Whether this resource is expected to be offline."),
|
||||
"neverAutoRemediate": booleanOption("Whether automated remediation must be refused for this resource."),
|
||||
"maintenanceStartAt": dateTimeOption("Maintenance window start time when set."),
|
||||
"maintenanceEndAt": dateTimeOption("Maintenance window end time when set."),
|
||||
"intentionallyOffline": booleanOption("Whether this resource is expected to be offline."),
|
||||
"neverAutoRemediate": booleanOption("Whether automated remediation must be refused for this resource."),
|
||||
"maintenanceStartAt": dateTimeOption("Maintenance window start time when set."),
|
||||
"maintenanceEndAt": dateTimeOption("Maintenance window end time when set."),
|
||||
"maintenanceRecurrence": openObjectOption("Weekly recurring maintenance schedule when configured instead of one-shot timestamps."),
|
||||
"maintenanceScope": map[string]any{
|
||||
"type": "string", "enum": []string{"resource", "resource_and_descendants"},
|
||||
"description": "Whether maintenance applies only to this resource or also to canonical descendants.",
|
||||
},
|
||||
"maintenanceReason": stringOption("Operator note attached to the maintenance window."),
|
||||
"criticality": stringOption("Operator-set finding sort hint: high, medium, low, or empty."),
|
||||
"note": stringOption("Operator note for this resource."),
|
||||
@@ -311,6 +316,22 @@ func operatorStateInputSchema() json.RawMessage {
|
||||
"format": "date-time",
|
||||
"description": "Maintenance window end time. Must be after maintenanceStartAt when both are set.",
|
||||
},
|
||||
"maintenanceRecurrence": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Weekly recurring schedule. Mutually exclusive with maintenanceStartAt/maintenanceEndAt.",
|
||||
"required": []string{"timezone", "weekdays", "startMinute", "endMinute"},
|
||||
"properties": map[string]any{
|
||||
"timezone": stringOption("IANA timezone for local schedule evaluation."),
|
||||
"weekdays": map[string]any{"type": "array", "minItems": 1, "uniqueItems": true, "items": map[string]any{"type": "string", "enum": []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}}},
|
||||
"startMinute": map[string]any{"type": "integer", "minimum": 0, "maximum": 1439},
|
||||
"endMinute": map[string]any{"type": "integer", "minimum": 0, "maximum": 1439},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"maintenanceScope": map[string]any{
|
||||
"type": "string", "enum": []string{"resource", "resource_and_descendants"},
|
||||
"description": "Defaults to resource. Descendant scope follows the canonical inventory hierarchy.",
|
||||
},
|
||||
"maintenanceReason": stringOption("Optional reason shown when findings are quieted by the maintenance window."),
|
||||
"criticality": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
@@ -141,18 +141,29 @@ func TestCanonicalManifestUsesSharedOperatorStateVocabulary(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputProperties, _ := inputSchema["properties"].(map[string]any)
|
||||
for _, field := range []string{"monitoringMode", "lifecycleState"} {
|
||||
for _, field := range []string{"monitoringMode", "lifecycleState", "maintenanceRecurrence", "maintenanceScope"} {
|
||||
if _, ok := inputProperties[field]; !ok {
|
||||
t.Fatalf("set_operator_state input schema missing %q: %v", field, inputProperties)
|
||||
}
|
||||
}
|
||||
recurrence, _ := inputProperties["maintenanceRecurrence"].(map[string]any)
|
||||
recurrenceProperties, _ := recurrence["properties"].(map[string]any)
|
||||
for _, field := range []string{"timezone", "weekdays", "startMinute", "endMinute"} {
|
||||
if _, ok := recurrenceProperties[field]; !ok {
|
||||
t.Fatalf("maintenanceRecurrence schema missing %q: %v", field, recurrenceProperties)
|
||||
}
|
||||
}
|
||||
scope, _ := inputProperties["maintenanceScope"].(map[string]any)
|
||||
if got := scope["enum"]; !reflect.DeepEqual(got, []any{"resource", "resource_and_descendants"}) {
|
||||
t.Fatalf("maintenanceScope enum = %v", got)
|
||||
}
|
||||
getCapability, _ := FindCapability(manifest.Capabilities, GetOperatorStateCapabilityName)
|
||||
var outputSchema map[string]any
|
||||
if err := json.Unmarshal(getCapability.OutputSchema, &outputSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputProperties, _ := outputSchema["properties"].(map[string]any)
|
||||
for _, field := range []string{"monitoringMode", "lifecycleState"} {
|
||||
for _, field := range []string{"monitoringMode", "lifecycleState", "maintenanceRecurrence", "maintenanceScope"} {
|
||||
if _, ok := outputProperties[field]; !ok {
|
||||
t.Fatalf("get_operator_state output schema missing %q: %v", field, outputProperties)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ type OperatorIntentContext struct {
|
||||
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
|
||||
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
|
||||
MaintenanceReason string `json:"maintenanceReason,omitempty"`
|
||||
MaintenanceSourceID string `json:"maintenanceSourceId,omitempty"`
|
||||
MaintenanceInherited bool `json:"maintenanceInherited,omitempty"`
|
||||
MaintenanceScope string `json:"maintenanceScope,omitempty"`
|
||||
}
|
||||
|
||||
func (c OperatorIntentContext) suppressionForSignal(signal string) (bool, string) {
|
||||
|
||||
@@ -251,22 +251,27 @@ func TestAlertIntentHonorsCanonicalResourcePolicyWithoutExplicitRules(t *testing
|
||||
|
||||
func TestCanonicalResourcePolicyGatesAllAlertWritersAndReconcilesExisting(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
mode := "normal"
|
||||
modes := map[string]string{"vm:101": "normal", "vm:202": "normal"}
|
||||
m.SetOperatorIntentContextResolver(func(resourceID string, observedAt time.Time) (OperatorIntentContext, bool) {
|
||||
return OperatorIntentContext{MonitoringMode: mode, LifecycleState: "active"}, true
|
||||
return OperatorIntentContext{MonitoringMode: modes[resourceID], LifecycleState: "active"}, true
|
||||
})
|
||||
|
||||
existing := &Alert{ID: "backup-vm-101", ResourceID: "vm:101", Type: "backup-age"}
|
||||
unaffected := &Alert{ID: "backup-vm-202", ResourceID: "vm:202", Type: "backup-age"}
|
||||
m.mu.Lock()
|
||||
m.setActiveAlertNoLock(existing.ID, existing)
|
||||
m.setActiveAlertNoLock(unaffected.ID, unaffected)
|
||||
m.mu.Unlock()
|
||||
if got := len(m.GetActiveAlerts()); got != 1 {
|
||||
t.Fatalf("active alerts before mute = %d, want 1", got)
|
||||
if got := len(m.GetActiveAlerts()); got != 2 {
|
||||
t.Fatalf("active alerts before mute = %d, want 2", got)
|
||||
}
|
||||
|
||||
mode = "muted"
|
||||
if cleared := m.ReconcileResourceOperatorState("vm:101"); cleared != 1 {
|
||||
t.Fatalf("ReconcileResourceOperatorState() cleared = %d, want 1", cleared)
|
||||
modes["vm:101"] = "muted"
|
||||
if cleared := m.ReconcileOperatorIntentState(); cleared != 1 {
|
||||
t.Fatalf("ReconcileOperatorIntentState() cleared = %d, want 1", cleared)
|
||||
}
|
||||
if !testHasActiveAlert(t, m, unaffected.ID) {
|
||||
t.Fatal("global operator-intent reconciliation cleared an unaffected alert")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -84,3 +84,33 @@ func (m *Manager) ReconcileResourceOperatorState(resourceID string) int {
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
// ReconcileOperatorIntentState re-evaluates every active alert after a policy
|
||||
// mutation. Maintenance scope can flow from a parent to any depth of
|
||||
// descendants, so an exact-resource reconciliation is not sufficient when a
|
||||
// host window starts, changes scope, or is cleared.
|
||||
func (m *Manager) ReconcileOperatorIntentState() int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
m.mu.Lock()
|
||||
alertIDs := make([]string, 0)
|
||||
for storageKey, alert := range m.activeAlerts {
|
||||
if alert == nil {
|
||||
continue
|
||||
}
|
||||
if suppressed, _ := m.operatorSuppressionForAlertNoLock(alert, now); suppressed {
|
||||
alertIDs = append(alertIDs, effectiveAlertID(alert, storageKey))
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
cleared := 0
|
||||
for _, alertID := range alertIDs {
|
||||
if m.ClearAlert(alertID) {
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
@@ -214,17 +214,19 @@ type AgentOperationsLoopStatus struct {
|
||||
// as a separate type so the bundle's JSON can be agent-stable even if
|
||||
// the underlying store type's JSON tags shift.
|
||||
type AgentResourceOperatorState struct {
|
||||
IntentionallyOffline bool `json:"intentionallyOffline"`
|
||||
MonitoringMode string `json:"monitoringMode"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
NeverAutoRemediate bool `json:"neverAutoRemediate"`
|
||||
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
|
||||
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
|
||||
MaintenanceReason string `json:"maintenanceReason,omitempty"`
|
||||
Criticality string `json:"criticality,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
SetAt time.Time `json:"setAt"`
|
||||
SetBy string `json:"setBy,omitempty"`
|
||||
IntentionallyOffline bool `json:"intentionallyOffline"`
|
||||
MonitoringMode string `json:"monitoringMode"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
NeverAutoRemediate bool `json:"neverAutoRemediate"`
|
||||
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
|
||||
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
|
||||
MaintenanceRecurrence *unified.RecurringMaintenanceWindow `json:"maintenanceRecurrence,omitempty"`
|
||||
MaintenanceScope string `json:"maintenanceScope"`
|
||||
MaintenanceReason string `json:"maintenanceReason,omitempty"`
|
||||
Criticality string `json:"criticality,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
SetAt time.Time `json:"setAt"`
|
||||
SetBy string `json:"setBy,omitempty"`
|
||||
// MaintenanceWindowActive reports whether a window covers `now` —
|
||||
// computed once on the server so agents don't need to re-evaluate
|
||||
// the start/end timestamps client-side.
|
||||
@@ -1339,6 +1341,8 @@ func projectAgentResourceOperatorState(
|
||||
NeverAutoRemediate: state.BlocksRemediation(),
|
||||
MaintenanceStartAt: state.MaintenanceStartAt,
|
||||
MaintenanceEndAt: state.MaintenanceEndAt,
|
||||
MaintenanceRecurrence: state.MaintenanceRecurrence,
|
||||
MaintenanceScope: string(state.MaintenanceScope),
|
||||
MaintenanceReason: state.MaintenanceReason,
|
||||
Criticality: string(state.Criticality),
|
||||
Note: state.Note,
|
||||
|
||||
@@ -18,24 +18,39 @@ import (
|
||||
// adapts to and from `unified.ResourceOperatorState` so the storage
|
||||
// type's evolution stays decoupled from the wire format.
|
||||
type resourceOperatorStateAPI struct {
|
||||
CanonicalID string `json:"canonicalId"`
|
||||
MonitoringMode string `json:"monitoringMode"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
IntentionallyOffline bool `json:"intentionallyOffline"`
|
||||
NeverAutoRemediate bool `json:"neverAutoRemediate"`
|
||||
AutoRemediationPolicy unified.AutoRemediationPolicy `json:"autoRemediationPolicy"`
|
||||
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
|
||||
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
|
||||
MaintenanceReason string `json:"maintenanceReason,omitempty"`
|
||||
Criticality string `json:"criticality,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
SetAt time.Time `json:"setAt"`
|
||||
SetBy string `json:"setBy,omitempty"`
|
||||
CanonicalID string `json:"canonicalId"`
|
||||
MonitoringMode string `json:"monitoringMode"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
IntentionallyOffline bool `json:"intentionallyOffline"`
|
||||
NeverAutoRemediate bool `json:"neverAutoRemediate"`
|
||||
AutoRemediationPolicy unified.AutoRemediationPolicy `json:"autoRemediationPolicy"`
|
||||
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
|
||||
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
|
||||
MaintenanceRecurrence *unified.RecurringMaintenanceWindow `json:"maintenanceRecurrence,omitempty"`
|
||||
MaintenanceScope string `json:"maintenanceScope"`
|
||||
MaintenanceReason string `json:"maintenanceReason,omitempty"`
|
||||
MaintenanceWindowActive bool `json:"maintenanceWindowActive"`
|
||||
MaintenanceActiveStartAt *time.Time `json:"maintenanceActiveStartAt,omitempty"`
|
||||
MaintenanceActiveEndAt *time.Time `json:"maintenanceActiveEndAt,omitempty"`
|
||||
Criticality string `json:"criticality,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
SetAt time.Time `json:"setAt"`
|
||||
SetBy string `json:"setBy,omitempty"`
|
||||
}
|
||||
|
||||
// resourceOperatorStateLookupAPI is the UI-safe read envelope. The canonical
|
||||
// agent/API contract keeps a missing explicit record as 404 so callers can
|
||||
// branch on operator_state_not_set. Interactive clients use view=lookup to
|
||||
// receive the same distinction as data instead of generating a routine failed
|
||||
// network request for every newly discovered resource.
|
||||
type resourceOperatorStateLookupAPI struct {
|
||||
Configured bool `json:"configured"`
|
||||
State *resourceOperatorStateAPI `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
func toResourceOperatorStateAPI(state unified.ResourceOperatorState) resourceOperatorStateAPI {
|
||||
state = unified.NormalizeResourceOperatorState(state)
|
||||
return resourceOperatorStateAPI{
|
||||
result := resourceOperatorStateAPI{
|
||||
CanonicalID: state.CanonicalID,
|
||||
MonitoringMode: string(state.MonitoringMode),
|
||||
LifecycleState: string(state.LifecycleState),
|
||||
@@ -44,12 +59,21 @@ func toResourceOperatorStateAPI(state unified.ResourceOperatorState) resourceOpe
|
||||
AutoRemediationPolicy: state.AutoRemediationPolicy,
|
||||
MaintenanceStartAt: state.MaintenanceStartAt,
|
||||
MaintenanceEndAt: state.MaintenanceEndAt,
|
||||
MaintenanceRecurrence: state.MaintenanceRecurrence,
|
||||
MaintenanceScope: string(state.MaintenanceScope),
|
||||
MaintenanceReason: state.MaintenanceReason,
|
||||
Criticality: string(state.Criticality),
|
||||
Note: state.Note,
|
||||
SetAt: state.SetAt,
|
||||
SetBy: state.SetBy,
|
||||
}
|
||||
if occurrence, active := state.ActiveMaintenanceOccurrenceAt(time.Now().UTC()); active {
|
||||
startAt, endAt := occurrence.StartAt, occurrence.EndAt
|
||||
result.MaintenanceWindowActive = true
|
||||
result.MaintenanceActiveStartAt = &startAt
|
||||
result.MaintenanceActiveEndAt = &endAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleResourceOperatorState dispatches GET / PUT / DELETE on
|
||||
@@ -92,6 +116,15 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("view") == "lookup" {
|
||||
result := resourceOperatorStateLookupAPI{Configured: found}
|
||||
if found {
|
||||
wireState := toResourceOperatorStateAPI(state)
|
||||
result.State = &wireState
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeOperatorStateNotSet,
|
||||
"No operator-set state recorded for this resource.")
|
||||
@@ -116,6 +149,8 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r
|
||||
AutoRemediationPolicy: payload.AutoRemediationPolicy,
|
||||
MaintenanceStartAt: payload.MaintenanceStartAt,
|
||||
MaintenanceEndAt: payload.MaintenanceEndAt,
|
||||
MaintenanceRecurrence: payload.MaintenanceRecurrence,
|
||||
MaintenanceScope: unified.MaintenanceScope(payload.MaintenanceScope),
|
||||
MaintenanceReason: payload.MaintenanceReason,
|
||||
Criticality: unified.ResourceCriticality(payload.Criticality),
|
||||
Note: payload.Note,
|
||||
|
||||
@@ -64,6 +64,25 @@ func TestHandleResourceOperatorState_GetReturns404WhenUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResourceOperatorState_LookupViewReturnsCleanUnsetEnvelope(t *testing.T) {
|
||||
h := newOperatorStateHandlers(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/operator-state?view=lookup", nil)
|
||||
|
||||
h.HandleResourceOperatorState(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 lookup envelope on unset state; got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body resourceOperatorStateLookupAPI
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("lookup body must be JSON; got %q", rec.Body.String())
|
||||
}
|
||||
if body.Configured || body.State != nil {
|
||||
t.Fatalf("unset lookup must preserve absence without a synthetic state: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResourceOperatorState_PutPersistsAndGetReturns200(t *testing.T) {
|
||||
h := newOperatorStateHandlers(t)
|
||||
|
||||
|
||||
@@ -509,6 +509,11 @@ func (r *Router) setupRoutes() {
|
||||
}
|
||||
seen[monitor] = struct{}{}
|
||||
if reconciler, ok := any(monitor.GetAlertManager()).(interface {
|
||||
ReconcileOperatorIntentState() int
|
||||
}); ok {
|
||||
reconciler.ReconcileOperatorIntentState()
|
||||
monitor.SyncAlertState()
|
||||
} else if reconciler, ok := any(monitor.GetAlertManager()).(interface {
|
||||
ReconcileResourceOperatorState(string) int
|
||||
}); ok {
|
||||
reconciler.ReconcileResourceOperatorState(resourceID)
|
||||
|
||||
@@ -134,27 +134,18 @@ func (s *Sentinel) tickOnce(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if state.MaintenanceStartAt == nil || state.MaintenanceEndAt == nil {
|
||||
continue
|
||||
for _, occurrence := range state.MaintenanceOccurrencesEndingBetween(cutoff, now) {
|
||||
s.evaluateForOccurrence(state, occurrence, store, now)
|
||||
}
|
||||
if !state.MaintenanceEndAt.Before(now) && !state.MaintenanceEndAt.Equal(now) {
|
||||
// Window hasn't ended yet.
|
||||
continue
|
||||
}
|
||||
if state.MaintenanceEndAt.Before(cutoff) {
|
||||
// Window ended too long ago — don't backfill ancient events.
|
||||
continue
|
||||
}
|
||||
s.evaluateForState(state, store, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) evaluateForState(state unified.ResourceOperatorState, store unified.ResourceStore, now time.Time) {
|
||||
func (s *Sentinel) evaluateForOccurrence(state unified.ResourceOperatorState, occurrence unified.MaintenanceWindowOccurrence, store unified.ResourceStore, now time.Time) {
|
||||
canonicalID := unified.CanonicalResourceID(state.CanonicalID)
|
||||
if canonicalID == "" || state.MaintenanceEndAt == nil {
|
||||
if canonicalID == "" || occurrence.StartAt.IsZero() || occurrence.EndAt.IsZero() {
|
||||
return
|
||||
}
|
||||
if _, exists, err := store.FindLoopReportByWindow(unified.LoopReportTypeMaintenanceVerification, canonicalID, *state.MaintenanceEndAt); err != nil {
|
||||
if _, exists, err := store.FindLoopReportByWindow(unified.LoopReportTypeMaintenanceVerification, canonicalID, occurrence.EndAt); err != nil {
|
||||
log.Debug().Err(err).Str("resource", canonicalID).Msg("maintenance-verification sentinel: dedupe lookup")
|
||||
return
|
||||
} else if exists {
|
||||
@@ -162,7 +153,7 @@ func (s *Sentinel) evaluateForState(state unified.ResourceOperatorState, store u
|
||||
return
|
||||
}
|
||||
|
||||
inputs := s.buildInputs(state, now)
|
||||
inputs := s.buildInputs(state, occurrence, now)
|
||||
report := EvaluateVerification(inputs)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
// A unique-constraint conflict (race against a parallel
|
||||
@@ -180,18 +171,18 @@ func (s *Sentinel) evaluateForState(state unified.ResourceOperatorState, store u
|
||||
// buildInputs gathers the deterministic input bundle for the
|
||||
// evaluator. Provider closures may be nil — the inputs simply carry
|
||||
// zero values in that case.
|
||||
func (s *Sentinel) buildInputs(state unified.ResourceOperatorState, now time.Time) VerificationInputs {
|
||||
func (s *Sentinel) buildInputs(state unified.ResourceOperatorState, occurrence unified.MaintenanceWindowOccurrence, now time.Time) VerificationInputs {
|
||||
canonicalID := unified.CanonicalResourceID(state.CanonicalID)
|
||||
stateForOccurrence := cloneOperatorState(state)
|
||||
startAt, endAt := occurrence.StartAt.UTC(), occurrence.EndAt.UTC()
|
||||
stateForOccurrence.MaintenanceStartAt = &startAt
|
||||
stateForOccurrence.MaintenanceEndAt = &endAt
|
||||
inputs := VerificationInputs{
|
||||
ResourceID: canonicalID,
|
||||
OperatorState: cloneOperatorState(state),
|
||||
Now: now,
|
||||
}
|
||||
if state.MaintenanceStartAt != nil {
|
||||
inputs.WindowStartedAt = state.MaintenanceStartAt.UTC()
|
||||
}
|
||||
if state.MaintenanceEndAt != nil {
|
||||
inputs.WindowEndedAt = state.MaintenanceEndAt.UTC()
|
||||
ResourceID: canonicalID,
|
||||
OperatorState: stateForOccurrence,
|
||||
Now: now,
|
||||
WindowStartedAt: startAt,
|
||||
WindowEndedAt: endAt,
|
||||
}
|
||||
if s.providers.ActiveAlerts != nil {
|
||||
inputs.ActiveAlerts = s.providers.ActiveAlerts(s.orgID, canonicalID)
|
||||
@@ -224,6 +215,11 @@ func cloneOperatorState(s unified.ResourceOperatorState) *unified.ResourceOperat
|
||||
t := *s.MaintenanceEndAt
|
||||
clone.MaintenanceEndAt = &t
|
||||
}
|
||||
if s.MaintenanceRecurrence != nil {
|
||||
recurrence := *s.MaintenanceRecurrence
|
||||
recurrence.Weekdays = append([]string(nil), s.MaintenanceRecurrence.Weekdays...)
|
||||
clone.MaintenanceRecurrence = &recurrence
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
@@ -261,11 +257,13 @@ func (s *Sentinel) EvaluateOnce(ctx context.Context, canonicalID string) (unifie
|
||||
if !found {
|
||||
return unified.LoopReport{}, fmt.Errorf("maintenancesentinel: no operator state for %q", canonicalID)
|
||||
}
|
||||
if state.MaintenanceStartAt == nil || state.MaintenanceEndAt == nil {
|
||||
now := s.now()
|
||||
occurrences := state.MaintenanceOccurrencesEndingBetween(now.Add(-s.lookbackLimit), now)
|
||||
if len(occurrences) == 0 {
|
||||
return unified.LoopReport{}, fmt.Errorf("maintenancesentinel: resource %q has no maintenance window to verify", canonicalID)
|
||||
}
|
||||
now := s.now()
|
||||
inputs := s.buildInputs(state, now)
|
||||
occurrence := occurrences[len(occurrences)-1]
|
||||
inputs := s.buildInputs(state, occurrence, now)
|
||||
report := EvaluateVerification(inputs)
|
||||
report.ID = uniqueRerunID(store, report.ID)
|
||||
if err := store.RecordLoopReport(report); err != nil {
|
||||
|
||||
@@ -55,6 +55,41 @@ func TestSentinelTickOnceWritesReportAndDedupes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelTickOnceVerifiesEveryEndedRecurringOccurrence(t *testing.T) {
|
||||
now := time.Date(2026, 8, 27, 5, 0, 0, 0, time.UTC) // Thursday
|
||||
store := unified.NewMemoryStore()
|
||||
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
||||
CanonicalID: "node:pve-a",
|
||||
MaintenanceRecurrence: &unified.RecurringMaintenanceWindow{
|
||||
Timezone: "UTC", Weekdays: []string{"wednesday", "thursday"}, StartMinute: 120, EndMinute: 180,
|
||||
},
|
||||
MaintenanceScope: unified.MaintenanceScopeResourceAndDescendants,
|
||||
SetAt: now.Add(-48 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinel, err := New(Config{LookbackLimit: 7 * 24 * time.Hour}, Providers{
|
||||
Stores: func(string) (unified.ResourceStore, error) { return store, nil },
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinel.tickOnce(context.Background())
|
||||
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "node:pve-a", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("recurring verification reports = %d, want one per ended occurrence", len(reports))
|
||||
}
|
||||
sentinel.tickOnce(context.Background())
|
||||
reports, _ = store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, "node:pve-a", 0)
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("recurring verification dedupe failed: %d reports", len(reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceSentinelTickOnceWritesTimelineEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
windowStart := now.Add(-time.Hour)
|
||||
|
||||
@@ -19,6 +19,10 @@ type resourceIntentIdentityReader interface {
|
||||
ResolveCanonicalResourceID(ref string) (string, bool)
|
||||
}
|
||||
|
||||
type resourceIntentAncestorReader interface {
|
||||
ResolveCanonicalResourceAncestors(ref string) []string
|
||||
}
|
||||
|
||||
func (m *Monitor) installOperatorIntentResolver(store ResourceStoreInterface) {
|
||||
if m == nil || m.alertManager == nil {
|
||||
return
|
||||
@@ -34,7 +38,8 @@ func (m *Monitor) installOperatorIntentResolver(store ResourceStoreInterface) {
|
||||
m.alertManager.SetOperatorIntentContextResolver(nil)
|
||||
return
|
||||
}
|
||||
m.alertManager.SetOperatorIntentContextResolver(func(resourceID string, _ time.Time) (alerts.OperatorIntentContext, bool) {
|
||||
ancestorReader, hasAncestorReader := store.(resourceIntentAncestorReader)
|
||||
m.alertManager.SetOperatorIntentContextResolver(func(resourceID string, now time.Time) (alerts.OperatorIntentContext, bool) {
|
||||
if hasIdentityReader {
|
||||
if canonicalID, found := identityReader.ResolveCanonicalResourceID(resourceID); found {
|
||||
resourceID = canonicalID
|
||||
@@ -45,17 +50,50 @@ func (m *Monitor) installOperatorIntentResolver(store ResourceStoreInterface) {
|
||||
log.Warn().Err(err).Str("resourceID", resourceID).Msg("Failed to read operator state for alert intent")
|
||||
return alerts.OperatorIntentContext{}, false
|
||||
}
|
||||
if !found {
|
||||
return alerts.OperatorIntentContext{}, false
|
||||
context := alerts.OperatorIntentContext{}
|
||||
if found {
|
||||
context = alerts.OperatorIntentContext{
|
||||
IntentionallyOffline: state.IntentionallyOffline,
|
||||
MonitoringMode: string(state.MonitoringMode),
|
||||
LifecycleState: string(state.LifecycleState),
|
||||
}
|
||||
}
|
||||
return alerts.OperatorIntentContext{
|
||||
IntentionallyOffline: state.IntentionallyOffline,
|
||||
MonitoringMode: string(state.MonitoringMode),
|
||||
LifecycleState: string(state.LifecycleState),
|
||||
MaintenanceStartAt: state.MaintenanceStartAt,
|
||||
MaintenanceEndAt: state.MaintenanceEndAt,
|
||||
MaintenanceReason: state.MaintenanceReason,
|
||||
}, true
|
||||
|
||||
applyActiveMaintenance := func(candidate unifiedresources.ResourceOperatorState, sourceID string, inherited bool) {
|
||||
occurrence, active := candidate.ActiveMaintenanceOccurrenceAt(now)
|
||||
if !active {
|
||||
return
|
||||
}
|
||||
// Overlapping exact/inherited schedules remain suppressed until the
|
||||
// latest active end. This avoids promising an early delivery resume.
|
||||
if context.MaintenanceEndAt != nil && !occurrence.EndAt.After(*context.MaintenanceEndAt) {
|
||||
return
|
||||
}
|
||||
startAt, endAt := occurrence.StartAt, occurrence.EndAt
|
||||
context.MaintenanceStartAt = &startAt
|
||||
context.MaintenanceEndAt = &endAt
|
||||
context.MaintenanceReason = candidate.MaintenanceReason
|
||||
context.MaintenanceSourceID = sourceID
|
||||
context.MaintenanceInherited = inherited
|
||||
context.MaintenanceScope = string(candidate.MaintenanceScope)
|
||||
}
|
||||
if found {
|
||||
applyActiveMaintenance(state, resourceID, false)
|
||||
}
|
||||
if hasAncestorReader {
|
||||
for _, ancestorID := range ancestorReader.ResolveCanonicalResourceAncestors(resourceID) {
|
||||
ancestorState, ancestorFound, ancestorErr := reader.GetResourceOperatorState(ancestorID)
|
||||
if ancestorErr != nil {
|
||||
log.Warn().Err(ancestorErr).Str("resourceID", resourceID).Str("ancestorID", ancestorID).Msg("Failed to read inherited operator maintenance state")
|
||||
continue
|
||||
}
|
||||
if !ancestorFound || ancestorState.MaintenanceScope != unifiedresources.MaintenanceScopeResourceAndDescendants {
|
||||
continue
|
||||
}
|
||||
applyActiveMaintenance(ancestorState, ancestorID, true)
|
||||
}
|
||||
}
|
||||
return context, found || context.MaintenanceEndAt != nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,64 @@ func TestInstallOperatorIntentResolverProjectsCanonicalResourcePolicy(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallOperatorIntentResolverInheritsScopedMaintenanceFromParent(t *testing.T) {
|
||||
store := unifiedresources.NewMemoryStore()
|
||||
registry := unifiedresources.NewRegistry(store)
|
||||
parentID := "node:pve-a"
|
||||
registry.IngestResources([]unifiedresources.Resource{
|
||||
{ID: parentID, Type: unifiedresources.ResourceTypeAgent, Name: "pve-a"},
|
||||
{ID: "vm:101", Type: unifiedresources.ResourceTypeVM, Name: "database", ParentID: &parentID},
|
||||
})
|
||||
now := time.Now().UTC()
|
||||
start, end := now.Add(-time.Hour), now.Add(2*time.Hour)
|
||||
if err := store.SetResourceOperatorState(unifiedresources.ResourceOperatorState{
|
||||
CanonicalID: parentID,
|
||||
MaintenanceStartAt: &start,
|
||||
MaintenanceEndAt: &end,
|
||||
MaintenanceReason: "hypervisor patching",
|
||||
MaintenanceScope: unifiedresources.MaintenanceScopeResourceAndDescendants,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manager := alerts.NewManagerWithDataDir(t.TempDir())
|
||||
t.Cleanup(manager.Stop)
|
||||
monitor := &Monitor{alertManager: manager}
|
||||
monitor.installOperatorIntentResolver(unifiedresources.NewMonitorAdapter(registry))
|
||||
preview, err := manager.PreviewIntentPolicy(alerts.AlertIntentPolicyPreviewRequest{
|
||||
ResourceID: "vm:101", ResourceType: "vm", Signal: string(alerts.AlertIntentSignalOffline), ConditionActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.Reason != "operator_maintenance" || preview.Status != "expected_transient" {
|
||||
t.Fatalf("inherited maintenance preview = %+v", preview)
|
||||
}
|
||||
if preview.EligibleAt == nil || !preview.EligibleAt.Equal(end) {
|
||||
t.Fatalf("eligibleAt = %v, want %v", preview.EligibleAt, end)
|
||||
}
|
||||
|
||||
// Scope is explicit: the same parent window must not leak to descendants
|
||||
// when changed back to resource-only.
|
||||
if err := store.SetResourceOperatorState(unifiedresources.ResourceOperatorState{
|
||||
CanonicalID: parentID,
|
||||
MaintenanceStartAt: &start,
|
||||
MaintenanceEndAt: &end,
|
||||
MaintenanceScope: unifiedresources.MaintenanceScopeResource,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err = manager.PreviewIntentPolicy(alerts.AlertIntentPolicyPreviewRequest{
|
||||
ResourceID: "vm:101", ResourceType: "vm", Signal: string(alerts.AlertIntentSignalOffline), ConditionActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.Status != "would_activate" {
|
||||
t.Fatalf("resource-only parent window leaked to child: %+v", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformMonitorResourceIdentityConstructors(t *testing.T) {
|
||||
if got := PBSMonitorResourceID("backup-main"); got != "pbs-backup-main" {
|
||||
t.Fatalf("PBS monitor resource ID = %q", got)
|
||||
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorState, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT canonical_id, monitoring_mode, lifecycle_state,
|
||||
intentionally_offline, never_auto_remediate,
|
||||
maintenance_start_at, maintenance_end_at, maintenance_reason,
|
||||
intentionally_offline, never_auto_remediate, auto_remediation_policy_json,
|
||||
maintenance_start_at, maintenance_end_at,
|
||||
maintenance_recurrence_json, maintenance_scope, maintenance_reason,
|
||||
criticality, note, set_at, set_by
|
||||
FROM resource_operator_state`)
|
||||
if err != nil {
|
||||
@@ -26,59 +27,11 @@ func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorSt
|
||||
|
||||
var out []ResourceOperatorState
|
||||
for rows.Next() {
|
||||
var (
|
||||
state ResourceOperatorState
|
||||
monitoringMode string
|
||||
lifecycleState string
|
||||
intentional int
|
||||
neverRemediate int
|
||||
startAt, endAt sql.NullTime
|
||||
reason sql.NullString
|
||||
criticality sql.NullString
|
||||
note sql.NullString
|
||||
setBy sql.NullString
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&state.CanonicalID,
|
||||
&monitoringMode,
|
||||
&lifecycleState,
|
||||
&intentional,
|
||||
&neverRemediate,
|
||||
&startAt,
|
||||
&endAt,
|
||||
&reason,
|
||||
&criticality,
|
||||
¬e,
|
||||
&state.SetAt,
|
||||
&setBy,
|
||||
); err != nil {
|
||||
state, err := scanResourceOperatorState(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource operator state row: %w", err)
|
||||
}
|
||||
state.MonitoringMode = ResourceMonitoringMode(monitoringMode)
|
||||
state.LifecycleState = ResourceLifecycleState(lifecycleState)
|
||||
state.IntentionallyOffline = intentional != 0
|
||||
state.NeverAutoRemediate = neverRemediate != 0
|
||||
if startAt.Valid {
|
||||
t := startAt.Time
|
||||
state.MaintenanceStartAt = &t
|
||||
}
|
||||
if endAt.Valid {
|
||||
t := endAt.Time
|
||||
state.MaintenanceEndAt = &t
|
||||
}
|
||||
if reason.Valid {
|
||||
state.MaintenanceReason = reason.String
|
||||
}
|
||||
if criticality.Valid {
|
||||
state.Criticality = ResourceCriticality(criticality.String)
|
||||
}
|
||||
if note.Valid {
|
||||
state.Note = note.String
|
||||
}
|
||||
if setBy.Valid {
|
||||
state.SetBy = setBy.String
|
||||
}
|
||||
out = append(out, NormalizeResourceOperatorState(state))
|
||||
out = append(out, state)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource operator state rows: %w", err)
|
||||
|
||||
@@ -146,6 +146,43 @@ func (a *MonitorAdapter) ResolveCanonicalResourceID(ref string) (string, bool) {
|
||||
return canonicalID, ok
|
||||
}
|
||||
|
||||
// ResolveCanonicalResourceAncestors returns the live canonical parent chain,
|
||||
// nearest parent first. It is used by policy consumers that deliberately opt
|
||||
// into inherited intent; ordinary per-resource state remains exact-match.
|
||||
func (a *MonitorAdapter) ResolveCanonicalResourceAncestors(ref string) []string {
|
||||
registry := a.currentRegistry()
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
_, canonicalID, ok := registry.GetByReference(ref)
|
||||
if !ok {
|
||||
canonicalID = CanonicalResourceID(ref)
|
||||
}
|
||||
seen := map[string]struct{}{canonicalID: {}}
|
||||
ancestors := make([]string, 0, 4)
|
||||
for canonicalID != "" {
|
||||
resource, found := registry.Get(canonicalID)
|
||||
if !found || resource.ParentID == nil {
|
||||
break
|
||||
}
|
||||
parentRef := CanonicalResourceID(*resource.ParentID)
|
||||
parentID := parentRef
|
||||
if _, resolvedParentID, resolved := registry.GetByReference(parentRef); resolved {
|
||||
parentID = resolvedParentID
|
||||
}
|
||||
if parentID == "" {
|
||||
break
|
||||
}
|
||||
if _, cycle := seen[parentID]; cycle {
|
||||
break
|
||||
}
|
||||
seen[parentID] = struct{}{}
|
||||
ancestors = append(ancestors, parentID)
|
||||
canonicalID = parentID
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
// LastRebuiltAt returns when the registry last published a generation. Zero
|
||||
// when no snapshot or supplemental ingest has completed yet.
|
||||
func (a *MonitorAdapter) LastRebuiltAt() time.Time {
|
||||
|
||||
@@ -2,6 +2,7 @@ package unifiedresources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -133,6 +134,30 @@ func TestMonitorAdapterResolvesCanonicalOperatorIntentCapabilities(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAdapterResolvesCanonicalResourceAncestorsNearestFirst(t *testing.T) {
|
||||
registry := NewRegistry(NewMemoryStore())
|
||||
adapter := NewMonitorAdapter(registry)
|
||||
clusterID := "cluster:analytics"
|
||||
nodeID := "node:pve-a"
|
||||
vmID := "vm:pve-a:101"
|
||||
adapter.PopulateSupplementalRecords(SourceProxmox, []IngestRecord{
|
||||
{SourceID: clusterID, Resource: Resource{ID: clusterID, Type: ResourceTypeAgent, Name: "analytics"}},
|
||||
{SourceID: nodeID, ParentSourceID: clusterID, Resource: Resource{ID: nodeID, Type: ResourceTypeAgent, Name: "pve-a"}},
|
||||
{SourceID: vmID, ParentSourceID: nodeID, Resource: Resource{ID: vmID, Type: ResourceTypeVM, Name: "vm-101"}},
|
||||
})
|
||||
|
||||
canonicalVM, found := adapter.ResolveCanonicalResourceID(vmID)
|
||||
if !found {
|
||||
t.Fatalf("ResolveCanonicalResourceID(%q) did not find VM", vmID)
|
||||
}
|
||||
canonicalNode, _ := adapter.ResolveCanonicalResourceID(nodeID)
|
||||
canonicalCluster, _ := adapter.ResolveCanonicalResourceID(clusterID)
|
||||
want := []string{canonicalNode, canonicalCluster}
|
||||
if got := adapter.ResolveCanonicalResourceAncestors(canonicalVM); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveCanonicalResourceAncestors() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAdapterPhysicalDiskReadStateRetainsProxmoxIdentityAfterSMARTMerge(t *testing.T) {
|
||||
adapter := NewMonitorAdapter(NewRegistry(nil))
|
||||
now := time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -63,6 +63,36 @@ type AutoRemediationPolicy struct {
|
||||
Window *AutoRemediationWindow `json:"window,omitempty"`
|
||||
}
|
||||
|
||||
// MaintenanceScope controls whether a window applies only to the resource it
|
||||
// is stored on or also to resources below it in the canonical inventory tree.
|
||||
// Descendant propagation is deliberately opt-in: a host maintenance window
|
||||
// should not silence guests unless the operator chose that scope.
|
||||
type MaintenanceScope string
|
||||
|
||||
const (
|
||||
MaintenanceScopeResource MaintenanceScope = "resource"
|
||||
MaintenanceScopeResourceAndDescendants MaintenanceScope = "resource_and_descendants"
|
||||
)
|
||||
|
||||
// RecurringMaintenanceWindow defines a weekly maintenance schedule in an IANA
|
||||
// timezone. Weekdays name the local day on which an occurrence starts.
|
||||
// StartMinute is inclusive and EndMinute is exclusive; end may be earlier than
|
||||
// start to represent an overnight window.
|
||||
type RecurringMaintenanceWindow struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Weekdays []string `json:"weekdays"`
|
||||
StartMinute int `json:"startMinute"`
|
||||
EndMinute int `json:"endMinute"`
|
||||
}
|
||||
|
||||
// MaintenanceWindowOccurrence is one concrete evaluated occurrence. Keeping
|
||||
// exact boundaries in the canonical model lets Alerts, Patrol, timelines, and
|
||||
// post-maintenance verification agree on when suppression ends.
|
||||
type MaintenanceWindowOccurrence struct {
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
}
|
||||
|
||||
// IsValidCriticality reports whether the value is empty or one of the three
|
||||
// canonical levels. Empty is valid (operator has not set a hint). Anything
|
||||
// else is rejected at the API boundary so freeform strings cannot accumulate
|
||||
@@ -148,6 +178,15 @@ type ResourceOperatorState struct {
|
||||
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
|
||||
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
|
||||
|
||||
// MaintenanceRecurrence is the recurring alternative to the one-shot
|
||||
// start/end pair. The two forms are mutually exclusive so there is only one
|
||||
// schedule to explain and audit for a resource.
|
||||
MaintenanceRecurrence *RecurringMaintenanceWindow `json:"maintenanceRecurrence,omitempty"`
|
||||
|
||||
// MaintenanceScope defaults to resource. resource_and_descendants is
|
||||
// resolved against the canonical unified-resource parent chain.
|
||||
MaintenanceScope MaintenanceScope `json:"maintenanceScope"`
|
||||
|
||||
// MaintenanceReason is freeform operator note attached to the window
|
||||
// for audit / Assistant context. Surfaced verbatim in the
|
||||
// auto-acknowledge note so the operator can see WHY future findings
|
||||
@@ -189,6 +228,7 @@ func (s ResourceOperatorState) IsEmpty() bool {
|
||||
s.AutoRemediationPolicy.Window == nil &&
|
||||
s.MaintenanceStartAt == nil &&
|
||||
s.MaintenanceEndAt == nil &&
|
||||
s.MaintenanceRecurrence == nil &&
|
||||
strings.TrimSpace(s.MaintenanceReason) == "" &&
|
||||
s.Criticality == "" &&
|
||||
strings.TrimSpace(s.Note) == ""
|
||||
@@ -220,19 +260,86 @@ func (s ResourceOperatorState) BlocksRemediation() bool {
|
||||
// maintenance window. Returns false when no window is configured, when only
|
||||
// one of start/end is set (treated as no window), or when end <= start.
|
||||
func (s ResourceOperatorState) IsInMaintenanceAt(now time.Time) bool {
|
||||
if s.MaintenanceStartAt == nil || s.MaintenanceEndAt == nil {
|
||||
return false
|
||||
_, ok := s.ActiveMaintenanceOccurrenceAt(now)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ActiveMaintenanceOccurrenceAt returns the exact one-shot or recurring
|
||||
// occurrence containing now. It fails closed for malformed schedules; writes
|
||||
// are still rejected by ValidateResourceOperatorState.
|
||||
func (s ResourceOperatorState) ActiveMaintenanceOccurrenceAt(now time.Time) (MaintenanceWindowOccurrence, bool) {
|
||||
if s.MaintenanceStartAt != nil && s.MaintenanceEndAt != nil &&
|
||||
s.MaintenanceEndAt.After(*s.MaintenanceStartAt) &&
|
||||
!now.Before(*s.MaintenanceStartAt) && now.Before(*s.MaintenanceEndAt) {
|
||||
return MaintenanceWindowOccurrence{StartAt: s.MaintenanceStartAt.UTC(), EndAt: s.MaintenanceEndAt.UTC()}, true
|
||||
}
|
||||
if !s.MaintenanceEndAt.After(*s.MaintenanceStartAt) {
|
||||
return false
|
||||
recurrence := NormalizeRecurringMaintenanceWindow(s.MaintenanceRecurrence)
|
||||
if recurrence == nil {
|
||||
return MaintenanceWindowOccurrence{}, false
|
||||
}
|
||||
if now.Before(*s.MaintenanceStartAt) {
|
||||
return false
|
||||
location, err := time.LoadLocation(recurrence.Timezone)
|
||||
if err != nil {
|
||||
return MaintenanceWindowOccurrence{}, false
|
||||
}
|
||||
if !now.Before(*s.MaintenanceEndAt) {
|
||||
return false
|
||||
localNow := now.In(location)
|
||||
for _, dayOffset := range []int{0, -1} {
|
||||
day := localNow.AddDate(0, 0, dayOffset)
|
||||
if !recurringMaintenanceIncludesWeekday(recurrence, day.Weekday()) {
|
||||
continue
|
||||
}
|
||||
start := time.Date(day.Year(), day.Month(), day.Day(), recurrence.StartMinute/60, recurrence.StartMinute%60, 0, 0, location)
|
||||
endDay := day
|
||||
if recurrence.EndMinute <= recurrence.StartMinute {
|
||||
endDay = day.AddDate(0, 0, 1)
|
||||
}
|
||||
end := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), recurrence.EndMinute/60, recurrence.EndMinute%60, 0, 0, location)
|
||||
if !localNow.Before(start) && localNow.Before(end) {
|
||||
return MaintenanceWindowOccurrence{StartAt: start.UTC(), EndAt: end.UTC()}, true
|
||||
}
|
||||
}
|
||||
return true
|
||||
return MaintenanceWindowOccurrence{}, false
|
||||
}
|
||||
|
||||
// MaintenanceOccurrencesEndingBetween returns concrete occurrences whose end
|
||||
// is in (since, until]. It gives the maintenance sentinel a bounded,
|
||||
// restart-safe way to verify every recurring window without inventing a
|
||||
// second scheduler or persisting mutable "last run" state.
|
||||
func (s ResourceOperatorState) MaintenanceOccurrencesEndingBetween(since, until time.Time) []MaintenanceWindowOccurrence {
|
||||
if until.Before(since) {
|
||||
return nil
|
||||
}
|
||||
occurrences := make([]MaintenanceWindowOccurrence, 0)
|
||||
if s.MaintenanceStartAt != nil && s.MaintenanceEndAt != nil &&
|
||||
s.MaintenanceEndAt.After(*s.MaintenanceStartAt) &&
|
||||
s.MaintenanceEndAt.After(since) && !s.MaintenanceEndAt.After(until) {
|
||||
occurrences = append(occurrences, MaintenanceWindowOccurrence{StartAt: s.MaintenanceStartAt.UTC(), EndAt: s.MaintenanceEndAt.UTC()})
|
||||
}
|
||||
recurrence := NormalizeRecurringMaintenanceWindow(s.MaintenanceRecurrence)
|
||||
if recurrence == nil {
|
||||
return occurrences
|
||||
}
|
||||
location, err := time.LoadLocation(recurrence.Timezone)
|
||||
if err != nil {
|
||||
return occurrences
|
||||
}
|
||||
firstDay := since.In(location).AddDate(0, 0, -1)
|
||||
lastDay := until.In(location)
|
||||
for day := time.Date(firstDay.Year(), firstDay.Month(), firstDay.Day(), 0, 0, 0, 0, location); !day.After(lastDay); day = day.AddDate(0, 0, 1) {
|
||||
if !recurringMaintenanceIncludesWeekday(recurrence, day.Weekday()) {
|
||||
continue
|
||||
}
|
||||
start := time.Date(day.Year(), day.Month(), day.Day(), recurrence.StartMinute/60, recurrence.StartMinute%60, 0, 0, location)
|
||||
endDay := day
|
||||
if recurrence.EndMinute <= recurrence.StartMinute {
|
||||
endDay = day.AddDate(0, 0, 1)
|
||||
}
|
||||
end := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), recurrence.EndMinute/60, recurrence.EndMinute%60, 0, 0, location)
|
||||
if end.After(since) && !end.After(until) {
|
||||
occurrences = append(occurrences, MaintenanceWindowOccurrence{StartAt: start.UTC(), EndAt: end.UTC()})
|
||||
}
|
||||
}
|
||||
sort.Slice(occurrences, func(i, j int) bool { return occurrences[i].EndAt.Before(occurrences[j].EndAt) })
|
||||
return occurrences
|
||||
}
|
||||
|
||||
// ErrResourceOperatorStateInvalid is returned by stores when the supplied
|
||||
@@ -273,6 +380,15 @@ func ValidateResourceOperatorState(state ResourceOperatorState) error {
|
||||
return fmt.Errorf("%w: maintenance end_at must be strictly after start_at", ErrResourceOperatorStateInvalid)
|
||||
}
|
||||
}
|
||||
if startSet && state.MaintenanceRecurrence != nil {
|
||||
return fmt.Errorf("%w: one-shot and recurring maintenance windows are mutually exclusive", ErrResourceOperatorStateInvalid)
|
||||
}
|
||||
if err := ValidateRecurringMaintenanceWindow(state.MaintenanceRecurrence); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrResourceOperatorStateInvalid, err)
|
||||
}
|
||||
if !IsValidMaintenanceScope(string(state.MaintenanceScope)) {
|
||||
return fmt.Errorf("%w: maintenance_scope %q is not one of (resource, resource_and_descendants)", ErrResourceOperatorStateInvalid, state.MaintenanceScope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -296,6 +412,11 @@ func NormalizeResourceOperatorState(state ResourceOperatorState) ResourceOperato
|
||||
state.LifecycleState = LifecycleStateActive
|
||||
}
|
||||
state.MaintenanceReason = strings.TrimSpace(state.MaintenanceReason)
|
||||
state.MaintenanceScope = MaintenanceScope(strings.ToLower(strings.TrimSpace(string(state.MaintenanceScope))))
|
||||
if state.MaintenanceScope == "" {
|
||||
state.MaintenanceScope = MaintenanceScopeResource
|
||||
}
|
||||
state.MaintenanceRecurrence = NormalizeRecurringMaintenanceWindow(state.MaintenanceRecurrence)
|
||||
state.Note = strings.TrimSpace(state.Note)
|
||||
state.SetBy = strings.TrimSpace(state.SetBy)
|
||||
state.Criticality = ResourceCriticality(strings.ToLower(strings.TrimSpace(string(state.Criticality))))
|
||||
@@ -303,6 +424,93 @@ func NormalizeResourceOperatorState(state ResourceOperatorState) ResourceOperato
|
||||
return state
|
||||
}
|
||||
|
||||
func IsValidMaintenanceScope(value string) bool {
|
||||
switch MaintenanceScope(value) {
|
||||
case MaintenanceScopeResource, MaintenanceScopeResourceAndDescendants:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var maintenanceWeekdayOrder = map[string]int{
|
||||
"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
|
||||
"friday": 4, "saturday": 5, "sunday": 6,
|
||||
}
|
||||
|
||||
// NormalizeRecurringMaintenanceWindow canonicalizes timezone and weekday
|
||||
// spelling/order without mutating the caller's schedule.
|
||||
func NormalizeRecurringMaintenanceWindow(window *RecurringMaintenanceWindow) *RecurringMaintenanceWindow {
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
normalized := *window
|
||||
normalized.Timezone = strings.TrimSpace(normalized.Timezone)
|
||||
seen := make(map[string]struct{}, len(normalized.Weekdays))
|
||||
weekdays := make([]string, 0, len(normalized.Weekdays))
|
||||
for _, weekday := range normalized.Weekdays {
|
||||
weekday = strings.ToLower(strings.TrimSpace(weekday))
|
||||
if weekday == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[weekday]; exists {
|
||||
continue
|
||||
}
|
||||
seen[weekday] = struct{}{}
|
||||
weekdays = append(weekdays, weekday)
|
||||
}
|
||||
sort.Slice(weekdays, func(i, j int) bool {
|
||||
left, leftOK := maintenanceWeekdayOrder[weekdays[i]]
|
||||
right, rightOK := maintenanceWeekdayOrder[weekdays[j]]
|
||||
if leftOK && rightOK {
|
||||
return left < right
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return weekdays[i] < weekdays[j]
|
||||
})
|
||||
normalized.Weekdays = weekdays
|
||||
return &normalized
|
||||
}
|
||||
|
||||
func ValidateRecurringMaintenanceWindow(window *RecurringMaintenanceWindow) error {
|
||||
window = NormalizeRecurringMaintenanceWindow(window)
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
if window.Timezone == "" {
|
||||
return errors.New("recurring maintenance requires an IANA timezone")
|
||||
}
|
||||
if _, err := time.LoadLocation(window.Timezone); err != nil {
|
||||
return fmt.Errorf("recurring maintenance timezone %q is invalid", window.Timezone)
|
||||
}
|
||||
if len(window.Weekdays) == 0 {
|
||||
return errors.New("recurring maintenance requires at least one weekday")
|
||||
}
|
||||
for _, weekday := range window.Weekdays {
|
||||
if _, ok := maintenanceWeekdayOrder[weekday]; !ok {
|
||||
return fmt.Errorf("recurring maintenance weekday %q is invalid", weekday)
|
||||
}
|
||||
}
|
||||
if window.StartMinute < 0 || window.StartMinute > 1439 || window.EndMinute < 0 || window.EndMinute > 1439 {
|
||||
return errors.New("recurring maintenance minutes must be between 0 and 1439")
|
||||
}
|
||||
if window.StartMinute == window.EndMinute {
|
||||
return errors.New("recurring maintenance start and end must differ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recurringMaintenanceIncludesWeekday(window *RecurringMaintenanceWindow, weekday time.Weekday) bool {
|
||||
name := strings.ToLower(weekday.String())
|
||||
for _, candidate := range window.Weekdays {
|
||||
if candidate == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeAutoRemediationPolicy returns a deterministic copy for storage,
|
||||
// hashing, and exact capability matching.
|
||||
func NormalizeAutoRemediationPolicy(policy AutoRemediationPolicy) AutoRemediationPolicy {
|
||||
@@ -428,9 +636,11 @@ const (
|
||||
)
|
||||
|
||||
type maintenanceWindowLifecycleSnapshot struct {
|
||||
start time.Time
|
||||
end time.Time
|
||||
reason string
|
||||
start *time.Time
|
||||
end *time.Time
|
||||
recurrence *RecurringMaintenanceWindow
|
||||
scope MaintenanceScope
|
||||
reason string
|
||||
}
|
||||
|
||||
// BuildMaintenanceWindowLifecycleChange returns the canonical resource
|
||||
@@ -486,15 +696,27 @@ func BuildMaintenanceWindowLifecycleChange(previous ResourceOperatorState, previ
|
||||
"operatorStateChange": "maintenance_window_lifecycle",
|
||||
}
|
||||
if beforeOK {
|
||||
metadata["previousMaintenanceStartAt"] = before.start.UTC().Format(time.RFC3339)
|
||||
metadata["previousMaintenanceEndAt"] = before.end.UTC().Format(time.RFC3339)
|
||||
if before.start != nil && before.end != nil {
|
||||
metadata["previousMaintenanceStartAt"] = before.start.UTC().Format(time.RFC3339)
|
||||
metadata["previousMaintenanceEndAt"] = before.end.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if before.recurrence != nil {
|
||||
metadata["previousMaintenanceRecurrence"] = before.recurrence
|
||||
}
|
||||
metadata["previousMaintenanceScope"] = before.scope
|
||||
if before.reason != "" {
|
||||
metadata["previousMaintenanceReason"] = before.reason
|
||||
}
|
||||
}
|
||||
if afterOK {
|
||||
metadata["maintenanceStartAt"] = after.start.UTC().Format(time.RFC3339)
|
||||
metadata["maintenanceEndAt"] = after.end.UTC().Format(time.RFC3339)
|
||||
if after.start != nil && after.end != nil {
|
||||
metadata["maintenanceStartAt"] = after.start.UTC().Format(time.RFC3339)
|
||||
metadata["maintenanceEndAt"] = after.end.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if after.recurrence != nil {
|
||||
metadata["maintenanceRecurrence"] = after.recurrence
|
||||
}
|
||||
metadata["maintenanceScope"] = after.scope
|
||||
if after.reason != "" {
|
||||
metadata["maintenanceReason"] = after.reason
|
||||
}
|
||||
@@ -517,18 +739,46 @@ func BuildMaintenanceWindowLifecycleChange(previous ResourceOperatorState, previ
|
||||
}
|
||||
|
||||
func maintenanceWindowSnapshot(state ResourceOperatorState) (maintenanceWindowLifecycleSnapshot, bool) {
|
||||
if state.MaintenanceStartAt == nil || state.MaintenanceEndAt == nil {
|
||||
state = NormalizeResourceOperatorState(state)
|
||||
if (state.MaintenanceStartAt == nil || state.MaintenanceEndAt == nil) && state.MaintenanceRecurrence == nil {
|
||||
return maintenanceWindowLifecycleSnapshot{}, false
|
||||
}
|
||||
var start, end *time.Time
|
||||
if state.MaintenanceStartAt != nil && state.MaintenanceEndAt != nil {
|
||||
startUTC := state.MaintenanceStartAt.UTC()
|
||||
endUTC := state.MaintenanceEndAt.UTC()
|
||||
start = &startUTC
|
||||
end = &endUTC
|
||||
}
|
||||
return maintenanceWindowLifecycleSnapshot{
|
||||
start: state.MaintenanceStartAt.UTC(),
|
||||
end: state.MaintenanceEndAt.UTC(),
|
||||
reason: strings.TrimSpace(state.MaintenanceReason),
|
||||
start: start,
|
||||
end: end,
|
||||
recurrence: NormalizeRecurringMaintenanceWindow(state.MaintenanceRecurrence),
|
||||
scope: state.MaintenanceScope,
|
||||
reason: strings.TrimSpace(state.MaintenanceReason),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s maintenanceWindowLifecycleSnapshot) equal(other maintenanceWindowLifecycleSnapshot) bool {
|
||||
return s.start.Equal(other.start) && s.end.Equal(other.end) && s.reason == other.reason
|
||||
if (s.start == nil) != (other.start == nil) || (s.end == nil) != (other.end == nil) ||
|
||||
(s.recurrence == nil) != (other.recurrence == nil) {
|
||||
return false
|
||||
}
|
||||
if s.start != nil && !s.start.Equal(*other.start) {
|
||||
return false
|
||||
}
|
||||
if s.end != nil && !s.end.Equal(*other.end) {
|
||||
return false
|
||||
}
|
||||
if s.recurrence != nil {
|
||||
if s.recurrence.Timezone != other.recurrence.Timezone ||
|
||||
s.recurrence.StartMinute != other.recurrence.StartMinute ||
|
||||
s.recurrence.EndMinute != other.recurrence.EndMinute ||
|
||||
strings.Join(s.recurrence.Weekdays, ",") != strings.Join(other.recurrence.Weekdays, ",") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s.scope == other.scope && s.reason == other.reason
|
||||
}
|
||||
|
||||
func maintenanceWindowObservedAt(previous ResourceOperatorState, previousFound bool, current ResourceOperatorState, currentFound bool) time.Time {
|
||||
@@ -545,13 +795,25 @@ func maintenanceWindowSummary(window maintenanceWindowLifecycleSnapshot, ok bool
|
||||
if !ok {
|
||||
return "no maintenance window"
|
||||
}
|
||||
summary := window.start.UTC().Format(time.RFC3339) + " to " + window.end.UTC().Format(time.RFC3339)
|
||||
summary := ""
|
||||
if window.start != nil && window.end != nil {
|
||||
summary = window.start.UTC().Format(time.RFC3339) + " to " + window.end.UTC().Format(time.RFC3339)
|
||||
} else if window.recurrence != nil {
|
||||
summary = fmt.Sprintf("%s %s-%s (%s)", strings.Join(window.recurrence.Weekdays, ", "), maintenanceMinuteSummary(window.recurrence.StartMinute), maintenanceMinuteSummary(window.recurrence.EndMinute), window.recurrence.Timezone)
|
||||
}
|
||||
if window.scope == MaintenanceScopeResourceAndDescendants {
|
||||
summary += ", including descendants"
|
||||
}
|
||||
if window.reason != "" {
|
||||
summary += " (" + window.reason + ")"
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func maintenanceMinuteSummary(minute int) string {
|
||||
return fmt.Sprintf("%02d:%02d", minute/60, minute%60)
|
||||
}
|
||||
|
||||
func maintenanceWindowLifecycleReason(event string) string {
|
||||
switch event {
|
||||
case MaintenanceWindowLifecycleEventScheduled:
|
||||
|
||||
@@ -83,6 +83,65 @@ func TestResourceOperatorState_IsInMaintenanceAt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestResourceOperatorStateRecurringMaintenance(t *testing.T) {
|
||||
state := NormalizeResourceOperatorState(ResourceOperatorState{
|
||||
CanonicalID: "node:pve-a",
|
||||
MaintenanceRecurrence: &RecurringMaintenanceWindow{
|
||||
Timezone: "Europe/London",
|
||||
Weekdays: []string{" TUESDAY ", "monday", "monday"},
|
||||
StartMinute: 23 * 60,
|
||||
EndMinute: 60,
|
||||
},
|
||||
MaintenanceScope: MaintenanceScopeResourceAndDescendants,
|
||||
})
|
||||
if got := strings.Join(state.MaintenanceRecurrence.Weekdays, ","); got != "monday,tuesday" {
|
||||
t.Fatalf("normalized weekdays = %q", got)
|
||||
}
|
||||
if err := ValidateResourceOperatorState(state); err != nil {
|
||||
t.Fatalf("valid recurring window rejected: %v", err)
|
||||
}
|
||||
|
||||
// Monday night continues into Tuesday because weekdays name the day on
|
||||
// which the occurrence starts.
|
||||
inside := time.Date(2026, 8, 25, 0, 30, 0, 0, time.FixedZone("BST", 3600))
|
||||
occurrence, active := state.ActiveMaintenanceOccurrenceAt(inside)
|
||||
if !active {
|
||||
t.Fatal("overnight Monday occurrence must remain active on Tuesday")
|
||||
}
|
||||
if occurrence.EndAt != time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) {
|
||||
t.Fatalf("occurrence end = %v", occurrence.EndAt)
|
||||
}
|
||||
if state.IsInMaintenanceAt(time.Date(2026, 8, 25, 1, 0, 0, 0, time.FixedZone("BST", 3600))) {
|
||||
t.Fatal("recurring interval must remain half-open at its local end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceOperatorStateRejectsAmbiguousRecurringMaintenance(t *testing.T) {
|
||||
start := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
end := start.Add(time.Hour)
|
||||
base := ResourceOperatorState{
|
||||
CanonicalID: "node:pve-a",
|
||||
MaintenanceStartAt: &start,
|
||||
MaintenanceEndAt: &end,
|
||||
MaintenanceRecurrence: &RecurringMaintenanceWindow{
|
||||
Timezone: "UTC", Weekdays: []string{"monday"}, StartMinute: 120, EndMinute: 180,
|
||||
},
|
||||
}
|
||||
if err := ValidateResourceOperatorState(base); !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
||||
t.Fatalf("one-shot plus recurrence error = %v", err)
|
||||
}
|
||||
base.MaintenanceStartAt, base.MaintenanceEndAt = nil, nil
|
||||
base.MaintenanceRecurrence.Weekdays = []string{"funday"}
|
||||
if err := ValidateResourceOperatorState(base); !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
||||
t.Fatalf("invalid weekday error = %v", err)
|
||||
}
|
||||
base.MaintenanceRecurrence.Weekdays = []string{"monday"}
|
||||
base.MaintenanceScope = "whole_world"
|
||||
if err := ValidateResourceOperatorState(base); !errors.Is(err, ErrResourceOperatorStateInvalid) {
|
||||
t.Fatalf("invalid scope error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceOperatorState(t *testing.T) {
|
||||
t.Run("rejects empty canonical id", func(t *testing.T) {
|
||||
err := ValidateResourceOperatorState(ResourceOperatorState{CanonicalID: " "})
|
||||
|
||||
@@ -556,6 +556,8 @@ func (s *SQLiteResourceStore) initSchema() error {
|
||||
auto_remediation_policy_json TEXT,
|
||||
maintenance_start_at DATETIME,
|
||||
maintenance_end_at DATETIME,
|
||||
maintenance_recurrence_json TEXT,
|
||||
maintenance_scope TEXT NOT NULL DEFAULT 'resource',
|
||||
maintenance_reason TEXT,
|
||||
criticality TEXT,
|
||||
note TEXT,
|
||||
@@ -657,6 +659,8 @@ func (s *SQLiteResourceStore) migrateResourceOperatorStateSchema() error {
|
||||
"monitoring_mode": "TEXT NOT NULL DEFAULT 'normal'",
|
||||
"lifecycle_state": "TEXT NOT NULL DEFAULT 'active'",
|
||||
"auto_remediation_policy_json": "TEXT",
|
||||
"maintenance_recurrence_json": "TEXT",
|
||||
"maintenance_scope": "TEXT NOT NULL DEFAULT 'resource'",
|
||||
}
|
||||
for name, definition := range definitions {
|
||||
if _, ok := columns[name]; ok {
|
||||
@@ -3014,7 +3018,8 @@ func getResourceOperatorStateSQL(queryer resourceOperatorStateQueryRower, canoni
|
||||
SELECT canonical_id, monitoring_mode, lifecycle_state,
|
||||
intentionally_offline, never_auto_remediate,
|
||||
auto_remediation_policy_json,
|
||||
maintenance_start_at, maintenance_end_at, maintenance_reason,
|
||||
maintenance_start_at, maintenance_end_at,
|
||||
maintenance_recurrence_json, maintenance_scope, maintenance_reason,
|
||||
criticality, note, set_at, set_by
|
||||
FROM resource_operator_state WHERE canonical_id = ?`, canonicalID)
|
||||
state, err := scanResourceOperatorState(row)
|
||||
@@ -3030,16 +3035,18 @@ func getResourceOperatorStateSQL(queryer resourceOperatorStateQueryRower, canoni
|
||||
func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOperatorState, error) {
|
||||
var state ResourceOperatorState
|
||||
var (
|
||||
monitoringMode string
|
||||
lifecycleState string
|
||||
intentional int
|
||||
neverRemediate int
|
||||
autoPolicyJSON sql.NullString
|
||||
startAt, endAt sql.NullTime
|
||||
reason sql.NullString
|
||||
criticality sql.NullString
|
||||
note sql.NullString
|
||||
setBy sql.NullString
|
||||
monitoringMode string
|
||||
lifecycleState string
|
||||
intentional int
|
||||
neverRemediate int
|
||||
autoPolicyJSON sql.NullString
|
||||
recurrenceJSON sql.NullString
|
||||
maintenanceScope string
|
||||
startAt, endAt sql.NullTime
|
||||
reason sql.NullString
|
||||
criticality sql.NullString
|
||||
note sql.NullString
|
||||
setBy sql.NullString
|
||||
)
|
||||
if err := scanner.Scan(
|
||||
&state.CanonicalID,
|
||||
@@ -3050,6 +3057,8 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp
|
||||
&autoPolicyJSON,
|
||||
&startAt,
|
||||
&endAt,
|
||||
&recurrenceJSON,
|
||||
&maintenanceScope,
|
||||
&reason,
|
||||
&criticality,
|
||||
¬e,
|
||||
@@ -3062,6 +3071,7 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp
|
||||
state.LifecycleState = ResourceLifecycleState(lifecycleState)
|
||||
state.IntentionallyOffline = intentional != 0
|
||||
state.NeverAutoRemediate = neverRemediate != 0
|
||||
state.MaintenanceScope = MaintenanceScope(maintenanceScope)
|
||||
if autoPolicyJSON.Valid && strings.TrimSpace(autoPolicyJSON.String) != "" {
|
||||
if err := json.Unmarshal([]byte(autoPolicyJSON.String), &state.AutoRemediationPolicy); err != nil {
|
||||
return ResourceOperatorState{}, fmt.Errorf("unmarshal auto remediation policy: %w", err)
|
||||
@@ -3079,6 +3089,11 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp
|
||||
t := endAt.Time
|
||||
state.MaintenanceEndAt = &t
|
||||
}
|
||||
if recurrenceJSON.Valid && strings.TrimSpace(recurrenceJSON.String) != "" {
|
||||
if err := json.Unmarshal([]byte(recurrenceJSON.String), &state.MaintenanceRecurrence); err != nil {
|
||||
return ResourceOperatorState{}, fmt.Errorf("unmarshal maintenance recurrence: %w", err)
|
||||
}
|
||||
}
|
||||
if reason.Valid {
|
||||
state.MaintenanceReason = reason.String
|
||||
}
|
||||
@@ -3127,6 +3142,7 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState
|
||||
note sql.NullString
|
||||
setBy sql.NullString
|
||||
autoPolicyJSON sql.NullString
|
||||
recurrenceJSON sql.NullString
|
||||
)
|
||||
if policy := NormalizeAutoRemediationPolicy(state.AutoRemediationPolicy); policy.Enabled || len(policy.CapabilityNames) > 0 || policy.Window != nil {
|
||||
encoded, err := json.Marshal(policy)
|
||||
@@ -3136,6 +3152,14 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState
|
||||
autoPolicyJSON.String = string(encoded)
|
||||
autoPolicyJSON.Valid = true
|
||||
}
|
||||
if recurrence := NormalizeRecurringMaintenanceWindow(state.MaintenanceRecurrence); recurrence != nil {
|
||||
encoded, err := json.Marshal(recurrence)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal maintenance recurrence: %w", err)
|
||||
}
|
||||
recurrenceJSON.String = string(encoded)
|
||||
recurrenceJSON.Valid = true
|
||||
}
|
||||
if state.MaintenanceStartAt != nil {
|
||||
startAt.Time = *state.MaintenanceStartAt
|
||||
startAt.Valid = true
|
||||
@@ -3165,9 +3189,10 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState
|
||||
canonical_id, monitoring_mode, lifecycle_state,
|
||||
intentionally_offline, never_auto_remediate,
|
||||
auto_remediation_policy_json,
|
||||
maintenance_start_at, maintenance_end_at, maintenance_reason,
|
||||
maintenance_start_at, maintenance_end_at,
|
||||
maintenance_recurrence_json, maintenance_scope, maintenance_reason,
|
||||
criticality, note, set_at, set_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(canonical_id) DO UPDATE SET
|
||||
monitoring_mode = excluded.monitoring_mode,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
@@ -3176,6 +3201,8 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState
|
||||
auto_remediation_policy_json = excluded.auto_remediation_policy_json,
|
||||
maintenance_start_at = excluded.maintenance_start_at,
|
||||
maintenance_end_at = excluded.maintenance_end_at,
|
||||
maintenance_recurrence_json = excluded.maintenance_recurrence_json,
|
||||
maintenance_scope = excluded.maintenance_scope,
|
||||
maintenance_reason = excluded.maintenance_reason,
|
||||
criticality = excluded.criticality,
|
||||
note = excluded.note,
|
||||
@@ -3189,6 +3216,8 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState
|
||||
autoPolicyJSON,
|
||||
startAt,
|
||||
endAt,
|
||||
recurrenceJSON,
|
||||
state.MaintenanceScope,
|
||||
reason,
|
||||
criticality,
|
||||
note,
|
||||
|
||||
@@ -2819,6 +2819,24 @@ func TestSQLiteResourceStore_ResourceOperatorState_RoundTrips(t *testing.T) {
|
||||
t.Errorf("maintenance_end_at: got %v want %v", got.MaintenanceEndAt, end)
|
||||
}
|
||||
|
||||
// Recurring maintenance uses additive JSON/scope columns and replaces the
|
||||
// one-shot form atomically on the same canonical row.
|
||||
want.MaintenanceStartAt, want.MaintenanceEndAt = nil, nil
|
||||
want.MaintenanceRecurrence = &RecurringMaintenanceWindow{
|
||||
Timezone: "Europe/London", Weekdays: []string{"sunday", "monday"}, StartMinute: 120, EndMinute: 240,
|
||||
}
|
||||
want.MaintenanceScope = MaintenanceScopeResourceAndDescendants
|
||||
if err := store.SetResourceOperatorState(want); err != nil {
|
||||
t.Fatalf("set recurring maintenance: %v", err)
|
||||
}
|
||||
got, _, _ = store.GetResourceOperatorState("vm:101")
|
||||
if got.MaintenanceStartAt != nil || got.MaintenanceEndAt != nil || got.MaintenanceRecurrence == nil {
|
||||
t.Fatalf("recurring maintenance did not replace one-shot fields: %+v", got)
|
||||
}
|
||||
if got.MaintenanceScope != MaintenanceScopeResourceAndDescendants || got.MaintenanceRecurrence.Timezone != "Europe/London" || strings.Join(got.MaintenanceRecurrence.Weekdays, ",") != "monday,sunday" {
|
||||
t.Fatalf("recurring maintenance did not round-trip canonically: %+v", got)
|
||||
}
|
||||
|
||||
// Upsert: Set again with different values should overwrite, not error.
|
||||
want.IntentionallyOffline = false
|
||||
want.Criticality = CriticalityLow
|
||||
|
||||
@@ -255,6 +255,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
policy_ids,
|
||||
[
|
||||
"maintenance-verification-runtime",
|
||||
"availability-certificate-runtime",
|
||||
"discovery-provider-runtime",
|
||||
"host-agent-ingest-runtime",
|
||||
|
||||
Reference in New Issue
Block a user