feat(alerts): add recurring scoped maintenance

This commit is contained in:
rcourtman
2026-08-27 18:20:03 +01:00
parent ce555ee09d
commit 3d4c43162a
39 changed files with 1762 additions and 794 deletions
@@ -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
+18 -15
View File
@@ -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>
@@ -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'],
+25 -4
View File
@@ -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",
+13 -2
View File
@@ -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)
}
+3
View File
@@ -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) {
+12 -7
View File
@@ -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
}
+15 -11
View File
@@ -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,
+49 -14
View File
@@ -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)
+5
View File
@@ -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)
+26 -28
View File
@@ -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)
+49 -11
View File
@@ -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,
&note,
&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: " "})
+42 -13
View File
@@ -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,
&note,
@@ -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,
+18
View File
@@ -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",