Merge remote-tracking branch 'origin/main' into fix/alert-config-keep-unsent-fields

This commit is contained in:
rcourtman
2026-10-07 17:32:37 +01:00
35 changed files with 1374 additions and 677 deletions
@@ -6851,13 +6851,11 @@ The related-resource correlation section now also comes from the shared
correlation formatter in `internal/ai/correlation`, so resource chat and
incident prompts reuse the same learned-edge wording instead of rebuilding a
second patrol-local bullet format.
The Patrol intelligence page now also fetches the learned correlation list
from the canonical AI correlations endpoint, so the global AI surface and the
resource drawer both expose the same learned edge evidence instead of only
showing a correlation count. The same page and drawer now render that list
through the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
card, so the learned-correlation layout and edge wording stay aligned across
both surfaces. That shared card also owns the correlation ordering and
The Patrol intelligence page renders no learned correlations. The resource
drawer renders its resource-scoped learned edges through the shared
`frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
card, so any surface that shows learned correlations keeps the same layout and
edge wording. That shared card also owns the correlation ordering and
truncation rule, so callers pass raw learned edges instead of page-specific
top-N slices.
Assistant finding handoffs now also receive a model-only finding briefing
@@ -6881,10 +6879,13 @@ and related context are policy-sanitized by the chat handoff runtime before
prompt injection, so governed resource names, IDs, aliases, nodes, paths, and
addresses are redacted or represented through the canonical AI-safe summary
instead of leaking through product prose.
The same page and drawer now also render their recent-change timeline through
the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
card, so the canonical recent-change layout and relative-time wording stay
aligned across both surfaces instead of being rebuilt as page-local feeds.
The Patrol page renders no recent-change timeline. The resource drawer renders
its latest canonical change through the shared
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
card, and its `Change history` list through the shared
`frontend-modern/src/utils/resourceChangePresentation.ts` helpers, so the
canonical recent-change wording stays aligned instead of being rebuilt per
surface.
The Patrol intelligence seed context now also prefers the canonical
unified-resource timeline before falling back to the patrol-local change
detector, so deterministic patrol context and resource detail context share
@@ -6922,8 +6923,9 @@ silently dropping the summary block.
The per-resource intelligence payload returned from
`/api/ai/intelligence?resource_id=...` now carries recent changes,
dependencies, dependents, correlations, and knowledge only; policy posture
stays on the system-wide intelligence summary and the Patrol governance card
instead of riding the resource-detail payload.
stays on the system-wide intelligence summary instead of riding the
resource-detail payload, and no frontend surface currently renders those
posture counts.
That same resource-intelligence payload also carries dependency and
dependent correlation context from unified-resource correlations, so the drawer
can show canonical correlation relationships without reconstructing them from the
@@ -6982,10 +6984,9 @@ slice that the prompt context uses.
The system-wide `/api/ai/intelligence` summary should also surface the same
canonical recent-change slice, alongside the count, so the aggregate payload
and the prompt context stay aligned on the same shared timeline source.
The frontend Patrol intelligence page now also consumes that canonical
summary payload directly through the shared AI client and store, so the
visible summary card stays aligned with the same recent-change slice that the
runtime and API contracts expose.
The frontend Patrol page reads that canonical summary payload through the
shared AI client and store, but only for its overall health; it renders no
summary card and no recent-change slice.
The Patrol runtime now also exports a canonical `runtime_state` alongside
`blocked_reason` in the Patrol status payload, so provider-availability and any
legacy managed-credit block conditions remain part of the governed runtime
@@ -1904,6 +1904,23 @@ well as its alerts.
`internal/alerts/host_unraid_lifecycle_test.go` pins the clear, the holds, the
restarted count and the pending-run and count cleanup.
### Proxmox node cleanup keeps platform metric alerts
`CleanupAlertsForNodes` runs on every Proxmox node poll (and every mock alert
tick) and removes alerts whose `Node` is not a current Proxmox node or PBS
instance. Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or
platform host in `Node` and a display label such as `TrueNAS Disk` or
`Kubernetes Pod` in `metadata.resourceType`, which the preserve list's
platform words never matched. The cleanup used to delete them silently on
each poll while their reducer incident stayed firing, so the next evaluation
re-created each one and handed it to the alert callback again as a new
alert. `shouldPreserveAlertOutsideNodeCleanup` now keeps any alert carrying
`metadata.platformType`, or whose label maps through
`CanonicalAlertResourceType` to a Kubernetes, TrueNAS or vSphere type.
`TestCleanupAlertsForNodesKeepsPlatformMetricAlerts` in
`internal/alerts/alerts_test.go` pins both signals, and its
sibling pins that a guest alert on a removed Proxmox node is still removed.
### Configured flapping thresholds remain reachable
Every accepted positive `FlappingThreshold`, including values above ten, must
@@ -1931,6 +1948,42 @@ controls in `internal/alerts/flapping_threshold_test.go` exercise both sweeps,
drained and retained windows, dispatch callbacks and delivery diagnosis. These
modeled-time controls are not installed notification-destination acceptance.
### Default thresholds keep a positive trigger's clear below it
PBS, node temperature, agent, Kubernetes, TrueNAS and vSphere global defaults
go through `normalizeThresholdPointer` in `internal/alerts/config/normalize.go`
from `UpdateConfig`. The shared persistence normalization in
`internal/config/persistence.go` applies the same normalizer, through
`NormalizeHysteresisThreshold`, to the agent, node temperature and storage
pairs. A missing or negative threshold takes the factory default and a zero
trigger is off. A positive trigger always keeps a clear below it: a missing
clear sits five points under the trigger, floored at 0, and a clear at or
above the trigger is repaired. Guest and node usage, storage and Docker
defaults hold the same bound through `EnsureValidHysteresis` in
`ValidateHysteresisThresholds`, `NormalizeStorageDefaults` and
`NormalizeDockerThreshold`. The canonical per-type entries (`nvme`, `sata` and
`hdd` in `DiskFillByType`; `nvme`, `sas` and `sata` in `DiskTempByType`, the
types the thresholds page edits) keep a positive trigger and follow the same
clear rule; a non-positive trigger resets the entry to its type default,
because the page cannot switch a type off on its own. Other per-type keys are
stored as written.
The thresholds page sends `max(0, trigger - 5)`, so a 1-5% default arrives
with clear 0 and must not fall back to the factory clear. Agent defaults used
to do that, storing `{trigger: 1, clear: 75}` for a 1% Machines CPU default,
and per-type disk entries replaced the whole pair, trigger included. The
evaluator already ignored a clear at or above the trigger
(`buildCanonicalMetricSpec` drops it and the reducer then clears at the
trigger), so those alerts fired and resolved the same way; `/api/alerts/config`
served the factory clear and firing alerts reported it as `clearThreshold`. A
repaired pair above the margin, such as `{50, 80}` written through the API,
now has a real recovery band at 45 where it used to clear at the trigger.
`internal/alerts/config/normalize_low_trigger_clear_test.go` pins every
default family and both per-type maps, `TestUpdateConfigKeepsLowTriggerClearBelowTrigger` in
`internal/alerts/config_validation_test.go` pins the saved config and the
firing alert's clear level, and the low-trigger and stored-clear tests in
`internal/config/persistence_test.go` pin the written file and load repair.
### Monitor-only delivery is terminal
Monitor-only alerts remain visible, but neither a firing nor a recovery
@@ -2782,6 +2835,22 @@ derived alert read-model and Last 24 Hours stat refresh for
shared acknowledgement owner instead of keeping its own alert mutation fork.
Future overview action behavior should extend that shared acknowledgement hook
instead of putting acknowledge mutations back into render shells.
Overview ages and bands read the shared clock. `AlertOverviewAlertCard.tsx`
passes its own `useRelativeTimeNow` reading as `now` to
`formatAlertOverviewStartedAgo` and `getMetricAlertPresentation`, and the Last
24 Hours count in `useAlertOverviewState.ts` reads the same clock, so all three
measure from the wall clock. The hook no longer exposes a minute `tick`: read
as "now", that tick measured a card mounted between ticks from up to a minute
earlier, so an alert raised 70 seconds ago read "this minute", an alert
raised since the last tick stayed out of the 24h count until the next one, a
reading already past the 10-minute stale cut-off led the card as live, and the
stale flip and 24h drop landed up to a minute late. A mounted card now turns stale,
and an alert leaves the 24h count, within one 30-second tick with no data
change. The delivery diagnoses refresh is a server read and keeps its own
minute interval rather than following the 30-second clock.
`AlertOverviewAlertCard.clock.test.tsx` pins the mid-tick mount, the stale flip
and the 24h drop under fake timers, and `useAlertOverviewState.test.tsx` pins
the diagnoses cadence.
Render-heavy alert overview ownership now routes through
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
@@ -3133,13 +3133,12 @@ a new API state machine, queue contract, or verification-accounting field.
strip after current work has been ruled out. Latest coverage may appear only
as bounded empty-state copy inside the current-findings surface, not as a
separate calm-day posture list
while keeping the learning counters backend-only coverage, so Patrol keeps health and findings primary and renders timeline, correlation, and policy-posture data as selected-item investigation context rather than as a separate headline product metric
while keeping the learning counters backend-only coverage, so Patrol keeps health and findings primary and does not render timeline, correlation, or policy-posture data as a headline product metric
and that secondary investigation context remains explanatory API evidence,
not a default workspace mode: the Patrol page may expand recent-change,
correlation, and policy-posture details only for an active Patrol finding or
an explicitly selected run record, and must not surface those backend
context payloads as a page-level forensic block from degraded summary health
alone
not a Patrol workspace mode: the Patrol page renders no recent-change,
correlation, or policy-posture details, whether or not a finding is active
or a run record is selected, and must not surface those backend context
payloads as a page-level forensic block
and the Patrol status presentation boundary, so trigger/scheduling status
from Patrol status APIs stays in the header/control surface instead of being
repeated as default status chrome, watch-only presentation says Patrol checks
@@ -3291,15 +3290,15 @@ a new API state machine, queue contract, or verification-accounting field.
workflow; Assistant handoffs remain contextual selected-finding actions, not
the canonical API completion step for Patrol current work.
and the canonical alert-triggered Patrol enqueue path in `internal/api/router.go`, so alert-fired Patrol work flows through the unified alert bridge and trigger manager instead of being duplicated by monitor callback wiring
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical recent-change timelines stay rendered through one governed frontend card instead of separate page-local list loops
and the shared `frontend-modern/src/utils/resourceChangePresentation.ts` formatter used by the summary page and resource drawer, so canonical change wording does not drift across surfaces
and the Patrol-owned `Details` context selector, so same-state recent-change
records from the canonical AI payload are normalized into changed-substate
wording before Patrol renders them or attaches them to Assistant, avoiding
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so a compact recent-change summary renders through one governed frontend card instead of a page-local list loop; the resource drawer's full `Change history` timeline is a separate list built on the shared `frontend-modern/src/utils/resourceChangePresentation.ts` helpers
and the shared `frontend-modern/src/utils/resourceChangePresentation.ts` formatter used by the resource drawer, so canonical change wording does not drift across surfaces
and the Patrol supporting-context boundary, so the Patrol page keeps
recent-change payloads off the page with no `Details` context selector or
default forensic block, and any Patrol surface that does render same-state
recent-change records from the canonical AI payload or attach them to
Assistant normalizes them into changed-substate wording first, avoiding
no-op operator copy such as `online` to `online` while preserving the
backend-owned timeline event, and so those context payloads stay behind a
compact operator-opened affordance rather than creating a default forensic
block on the Patrol page
backend-owned timeline event
and the `/api/ai/intelligence/changes` route plus `internal/api/contract_test.go`, so the canonical recent-changes endpoint stays on the same intelligence facade and contract snapshot instead of bypassing the shared timeline source
and the canonical policy-posture snapshot derived from unified resources, so sensitivity, routing, and redaction counts stay owned by the same AI summary contract instead of being reconstructed as a page-local governance rollup
and the resource-intelligence payload carried by the drawer AI card, so the resource-detail surface stays on one canonical intelligence contract instead of introducing a separate detail endpoint
@@ -3308,7 +3307,7 @@ a new API state machine, queue contract, or verification-accounting field.
and the Patrol page refresh lifecycle in `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, so slow or stalled secondary reads from that shared dashboard-load bundle may continue resolving in the background while the operator-facing Patrol refresh control remains generation-aware, timeout-bounded, and reusable once Patrol findings and status are already visible
and the Patrol header support drawer in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, so API-owned Patrol status and trigger facts can feed a secondary Schedule & model surface without turning provider model, schedule, trigger tuning, or background-only runtime-policy pauses into the primary Patrol control decision
and any frontend presentation of the AI summary's `policy_posture` counts, which must render that canonical snapshot through one shared component instead of page-local posture loops; no frontend surface renders those counts today, and the resource drawer stays on per-resource policy lines
and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so recent-change, learned-correlation, and policy-coverage summary text stays derived from the canonical AI payload in one place instead of as hook-local count and pluralization logic
and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so any Patrol recent-change, learned-correlation, or policy-coverage summary text is derived from the canonical AI payload in that one place instead of as hook-local count and pluralization logic; Patrol renders no such summary today
and the Watch-only forward-path handoffs owned by `frontend-modern/src/features/patrol/patrolControlPresentation.ts` together with the Actions inbox empty-state read of `GET /api/ai/patrol/autonomy`, so finding-level and inbox-level mode guidance consumes the canonical Patrol autonomy read/save contract through the Patrol state hook (`handleAutonomyChange`) instead of introducing a second mode mutation path or a page-local autonomy dialect
and that same Patrol investigation-context owner, so the current Patrol
assessment summary may open Assistant with bounded model-only assessment,
@@ -3354,7 +3353,7 @@ a new API state machine, queue contract, or verification-accounting field.
and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair
and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops
and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' infrastructure resource-link default, so the Patrol page, resource drawer, and problem-resource dashboard panels inherit the canonical resource-filter path construction instead of rebuilding infrastructure URLs inline
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' optional `buildResourceHref` input, so a surface that needs resource-filter links passes them into the shared card instead of rebuilding infrastructure URLs inline; the cards carry no link default since the cross-resource drilldown links were retired, and render resource labels as plain text
and the Patrol runtime-remediation destination shared with the AI settings endpoint, so summary actions, run-history runtime-failure actions, and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy
and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered
and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol`
@@ -3495,9 +3494,9 @@ a new API state machine, queue contract, or verification-accounting field.
score, current risk facts, verification facts, and action state needed for a
compact operator summary, but it must not imply a hero, card, duplicate
verdict layout, or expanded assessment panel. Normal Patrol page consumers
should keep explanatory assessment, verification, activity, and supporting
context in the owning Findings, Runs, and `Details` surfaces rather
than re-expanding the primary assessment strip.
should keep explanatory assessment, verification, and activity context in
the owning Findings and Runs surfaces rather than re-expanding the primary
assessment strip.
18. Keep Pulse Mobile relay credential minting and permission ownership on backend ownership: `internal/api/router_routes_auth_security.go`, `internal/api/security_tokens.go`, `internal/api/auth.go`, `internal/api/relay_mobile_capability.go`, `internal/api/router_routes_ai_relay.go`, and `frontend-modern/src/api/security.ts` may expose the canonical mobile runtime token creator and governed route gates, but browser callers must only consume that route and must not define the mobile runtime scope, compatibility gate list, route inventory, or token-purpose metadata locally.
19. Keep hosted tenant browser-session precedence on the shared auth boundary: `internal/api/auth.go`, `internal/api/contract_test.go`, and hosted tenant callers must treat a valid `pulse_session` as authoritative before any API-only token fallback or no-local-auth anonymous fallback, so cloud handoff can continue into protected hosted routes without flattening the operator back to `anonymous` or forcing a browser session through bearer-token-only mode after the tenant has minted API tokens.
That same shared auth boundary also owns hosted handoff authorization. `internal/api/cloud_handoff.go`,
@@ -4303,10 +4302,10 @@ the authoritative analysis outcome.
and the main Patrol page composition boundary, so once that governed
secondary area exists inside the Current issues and history workspace the same payloads must not
also drive a second page-level status strip elsewhere on the route
and the Patrol `Details` disclosure rule, so recent changes,
and the Patrol supporting-context rule, so recent changes,
learned correlations, and policy coverage stay backend and Assistant context
instead of advertising a parallel Patrol workflow on otherwise healthy fully
verified states or degraded summary health alone. First-party Patrol page
instead of advertising a parallel Patrol workflow in any page state, healthy
or degraded. First-party Patrol page
consumers must not turn those payloads into a generic Details/supporting
context panel; selected finding and run records remain the source of truth for
what the operator can inspect or do next.
@@ -3073,10 +3073,10 @@ Agent`), with the plain-language source phrase available through accessible
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`,
`frontend-modern/src/components/patrol/RunHistoryEntry.tsx`, and
`frontend-modern/src/utils/patrolRuntimeActions.ts` must keep
Patrol assessment, verification, and findings primary; surface recent
changes, learned correlations, and policy coverage only as backend,
Assistant, selected-finding, or selected-run context when investigation
makes that evidence relevant; and use Patrol/provider wording for the shared provider settings,
Patrol assessment, verification, and findings primary; keep recent
changes, learned correlations, and policy coverage out of Patrol-owned
shells as backend or Assistant context, with no selected-finding or
selected-run page section for them; and use Patrol/provider wording for the shared provider settings,
provider model, and provider circuit-breaker affordances instead of
generic AI labels inside Patrol-owned shells. The shared app shell in
`frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` must
@@ -3091,9 +3091,10 @@ Agent`), with the plain-language source phrase available through accessible
Assistant context rather than a default page section. Patrol initial data
refresh failures must stay inside the Patrol feature shell as one compact
stale-data retry banner; they must not replace the route with Suspense,
blank loading, raw transport errors, or page-local diagnostic panels. The Patrol
investigation-context owner normalizes same-state recent-change records into
changed-substate wording before Assistant handoff renders them. The same shared feature-shell
blank loading, raw transport errors, or page-local diagnostic panels. If a
Patrol Assistant handoff attaches recent-change records, the Patrol
investigation-context owner must first normalize same-state records into
changed-substate wording. The same shared feature-shell
boundary owns the
commercial-facing Patrol capability language: autonomy segmented controls
and run-history/result labels must present the operator-facing policy levels
@@ -4912,8 +4913,8 @@ supporting metrics under that strip must stay metric-oriented and must not
repeat assessment or verification labels as a second compact verdict row.
The collapsed Patrol assessment strip itself must remain a compact readout
rather than a headline-plus-paragraph block; explanatory assessment and
recommendation copy belongs in the owning Findings, Runs, `Details`,
or Assistant chat surfaces rather than a normal-path summary details expansion.
recommendation copy belongs in the owning Findings, Runs, or Assistant chat
surfaces rather than a normal-path summary details expansion.
That readout should lead with current operator state and score rather than
mixing a reassuring grade label with issue-state copy in the same line.
That same summary shell should also keep the shared Pulse surface neutral:
@@ -6782,8 +6783,8 @@ or polling lifecycle. The Patrol feature is the current reference shape:
feature shell, reads its active workspace view from the route through
`patrolWorkspaceRouting.ts` rather than holding it in component state, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
owns the runtime state machine, `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
owns the pure investigation-context summary and Patrol-to-Assistant operator
briefing derivation, including the rule that active findings, pending
owns the pure Patrol-to-Assistant handoff and operator briefing derivation,
including the rule that active findings, pending
approvals, and governed action references outrank secondary coverage caveats
when building the Assistant prompt, action label, and safety note,
`frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owns canonical AI
@@ -6860,17 +6861,11 @@ parallel page-level status strip above the current-work workspace.
That same composition rule applies to the workspace: the default path should
move directly into findings and run history instead of repeating runtime
context through a second pre-tab status strip.
`Details` follows that same composition rule. Recent changes,
learned correlations, and policy coverage belong behind an explicitly secondary
supporting-context affordance that only appears when Patrol has active findings
or a selected run that needs explanation; healthy fully verified Patrol states
and degraded summary health by themselves must not advertise that supporting
evidence as a peer workflow. The default workspace may show the compact
`Details` control, but the full panel must render only after the operator
opens it. When that disclosure expands, the workspace must explicitly label the
selected finding or run as Patrol's record and frame the supporting cards as
explanatory context rather than as a fresh Patrol result or raw evidence
console.
Supporting context follows that same composition rule. Recent changes,
learned correlations, and policy coverage stay off the Patrol workspace: it has
no `Details` control or supporting-context panel for them in any state, with or
without active findings or a selected run, so that evidence never competes
with current work as a peer workflow or reads as a fresh Patrol result.
Selected-run history should also suppress generic findings filter chrome and
read as a Patrol run record. Missing legacy `finding_ids` remains an internal
fail-closed scoping condition, but the visible caveat should say the finding
@@ -7182,6 +7177,12 @@ shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while
alert stats, filtered ordering, and single/bulk acknowledge runtime behavior.
Future overview control flow should extend that hook rather than restoring
action timers or acknowledge mutations to the tab shell.
The overview follows the shared-clock rule for relative ages: the alert card
reads `useRelativeTimeNow` for its started age and the live reading's stale
cut-off, and the hook reads it for the Last 24 Hours count. A hook-local
minute signal used as "now" is a table-local interval under that rule; the
hook keeps a minute interval only to refresh delivery diagnoses, a server read
whose cadence is not tied to the age clock.
Render-heavy overview ownership now lives in
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
@@ -514,13 +514,11 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
through an explicit history review affordance, keep the ledger visually
bounded, and return the user to the findings snapshot when a historical run is
selected. Broad supporting context such as recent changes, correlations, and
policy coverage may be offered only as a compact `Details` affordance
when there is an active Patrol finding or an explicitly selected history
snapshot to explain. The full `Details` panel must render only
after that affordance is opened; its copy must tell operators it explains
Patrol's recorded finding or run state and must not present the data as a
separate raw evidence console. Degraded health or historical recurrence alone
must not surface a page-level forensic context block.
policy coverage stays off the Patrol page under the supporting-context rule
above, even when a finding is active or a history snapshot is selected; the
page has no `Details` context affordance for it. Degraded health or
historical recurrence alone must not surface a page-level forensic context
block either.
The default Patrol surface must stay simple: the header, control selector,
and Open work workspace own the ordinary operator state. Patrol must not
render a separate always-visible status/activity/health strip just to prove
@@ -1218,7 +1216,7 @@ fix`, or `Explain` based on current finding state), while secondary
`Health A` with issue-state copy such as `Issues detected` in the collapsed
line. Do not add a normal-path assessment details expansion: assessment
explanation, verification detail, activity mix, and supporting metrics belong in the
owning Findings, Runs, or `Details` surfaces
owning Findings or Runs surfaces
instead of reopening the compact strip into a sparse status panel.
### Canonical operational attention workbench
@@ -1883,21 +1881,16 @@ activity mix in check language instead of asking operators to reconcile a
`Recently verified` headline with a busy Patrol strip elsewhere on the page.
Fix-verification checks belong to that same explanation layer as follow-up
checks, not as evidence of a fresh full-estate sweep.
The same hierarchy applies to the `Details` supporting context.
Correlations, recent changes, and policy posture are secondary explanation for
deeper investigation, so the default workspace may expose only the compact
context affordance near the findings/history controls. When that disclosure is
expanded, the page must render the panel as the immediate next workspace content
and explicitly tell operators that the selected finding or run record remains
the source of truth, while nearby activity, related patterns, and inspection
boundaries are context Patrol considered and do not change the finding or count
as a fresh Patrol run.
When Patrol is healthy and fully verified, that supporting-context disclosure
should stay out of the main page flow instead of advertising a second parallel
Patrol workflow with nothing active to explain.
That same operational context belongs behind the same secondary disclosure as
verification, not as a default full-width strip that competes with the findings
workspace. The workspace and shared
The same hierarchy keeps correlations, recent changes, and policy posture off
the Patrol page. They are secondary explanation for deeper investigation, so
the workspace offers no `Details` supporting-context disclosure for them in any
state, healthy or not: the selected finding or run record remains the Patrol
source of truth, and that nearby evidence must not return as a second parallel
Patrol workflow beside the findings and history controls.
Patrol's run and trigger context, by contrast, belongs in the secondary
`Finding options and history` workspace beside the findings and run history,
not as a default full-width strip that competes with the current issues. The
workspace and shared
`frontend-modern/src/utils/patrolRunPresentation.ts` helpers may carry latest
run kind/result, scoped-trigger state, and circuit-breaker warnings as factual
support when the user opens the relevant context or when the page must surface
@@ -2116,11 +2109,10 @@ The Patrol seed context and AI runtime prompt path now also share the same
correlation summary formatter from `internal/ai/correlation`, so learned-edge
wording and confidence/count annotations stay canonical across the prompt
surface instead of being rebuilt in each caller.
The Patrol page also now renders the canonical intelligence summary card
through the governed AI client and store, so the visible page summary and the
resource/timeline sections stay aligned on the same shared backend slice.
That same summary card now keeps recent changes and learned correlations
primary while leaving the broader learning counters as backend coverage, so
The Patrol page reads the canonical intelligence summary through the governed
AI client and store but renders no intelligence summary card: it passes that
summary's overall health to the Patrol findings panel, while recent changes,
learned correlations, and the broader learning counters stay off the page, so
the page does not present telemetry-style counts as a headline intelligence
story.
The shared AI summary payload also carries the canonical data-governance
@@ -2132,20 +2124,18 @@ correlation context plus canonical correlation evidence through the
resource-intelligence payload, so the resource-level AI card can surface
relationship reachability and learned edge patterns directly from the AI
contract instead of inventing a second correlation summary.
The Patrol intelligence page now also consumes the learned correlation list
from the canonical AI correlations endpoint through the shared
`frontend-modern/src/stores/aiIntelligence.ts` store, so the global summary
and the resource drawer both reflect the same learned edge evidence instead
of each page fetching its own copy. Both surfaces now render that evidence
through the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
card, so the correlation layout stays governed by one component instead of
two page-local card implementations. That shared card also owns the
correlation ordering and truncation rule, so the page and drawer hand it raw
correlation lists instead of slicing or re-sorting them locally.
The same page and drawer now also share the canonical
The Patrol page renders no learned correlations or recent changes. The
resource drawer renders its resource-scoped correlations from the
resource-intelligence payload through the shared
`frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
card and its latest canonical change through the shared
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
card for recent changes, so the timeline layout and relative-time wording
stay governed by one frontend feed instead of separate page-local loops.
card. Those cards own correlation ordering and truncation and the change
summary's layout and relative-time wording, so a caller of either card, Patrol
included, hands it raw lists instead of slicing or re-sorting them first. The
drawer's full `Change history` timeline is a separate list that sorts and
renders its own rows through the shared `resourceChangePresentation.ts`
helpers.
Patrol trial-entry surfaces are retired for normal self-hosted v6 GA.
`ApprovalSection.tsx` and `usePatrolIntelligenceState.ts` may link to explicit
plan, activation, recovery, support, or hosted handoff surfaces where
@@ -2194,26 +2184,25 @@ inline.
The shared
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and
`frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
cards now own the canonical Infrastructure resource-link default, so the
Patrol page and resource drawer inherit resource-filter href construction
through the shared summary cards instead of rebuilding local wrappers in each
surface.
cards carry no resource-link default since the cross-resource drilldown links
were retired, so they render resource labels as plain text. A surface that
needs resource links passes `buildResourceHref` to the shared card instead of
rebuilding a local card wrapper.
The Patrol intelligence page renders no data-governance posture counts: they
left the page with its supporting-context block, and the shared posture card
that rendered them has been removed. If Patrol shows posture counts again, it
must render them through one shared unified-resource component instead of a
Patrol-local count loop, and the resource drawer keeps only per-resource
policy lines.
That same Patrol summary surface now keeps health and findings primary while
making recent changes and learned correlations available only
through the on-demand `Details` context inspector, so expansion
lane concepts stay available for deeper investigation without reading as the
The Patrol page keeps health and findings primary and offers no recent-change
or learned-correlation inspector: its on-demand `Details` context block left
with the supporting-context section, so those expansion-lane concepts stay in
the resource drawer and backend investigation instead of reading as the
headline Patrol product story.
That secondary investigation-context summary now also routes through the
dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
owner, so the Patrol hook composes one canonical payload-to-summary derivation
instead of rebuilding recent-change, correlation, and governed-resource count
copy inline.
Patrol also derives no recent-change, correlation, or governed-resource count
summary. If such a summary returns, the payload-to-summary derivation belongs
in `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`,
and the Patrol hook must not rebuild that count copy inline.
The Patrol page's run-history tab label is now also tightened to `Runs`, while
the underlying run-history panel remains canonical for run-record review,
internal `finding_ids` scoping, and tool-call inspection. That copy change is
@@ -2235,19 +2224,21 @@ That same zero-coverage rule also applies to the expanded narrative sentence.
When Patrol checked none of a known scoped set, the run summary must still say
`Checked 0 of N scoped resources` rather than reverting to a generic `Patrol
completed` sentence that hides the failed coverage.
The Patrol page and resource drawer now also share the canonical
`frontend-modern/src/utils/resourceChangePresentation.ts` formatter so
The resource drawer formats recent changes through the canonical
`frontend-modern/src/utils/resourceChangePresentation.ts` formatter, and any
Patrol surface that shows recent changes must use the same formatter, so
recent-change kind and headline wording stays aligned wherever the canonical
timeline is surfaced.
Patrol-owned supporting context may still apply a bounded explanation-layer
normalization before rendering recent changes or attaching them to Assistant:
same-state timeline records must describe the changed substate, such as Docker
image status or command posture, instead of surfacing no-op wording like an
`online` to `online` transition in the Patrol page or Assistant handoff.
The Patrol page and resource drawer now keep canonical relationship semantics
in the backend correlation path, while the visible frontend stays
timeline-first and does not surface a separate relationship presentation
helper.
If Patrol renders recent changes or attaches them to an Assistant handoff, it
must first apply a bounded explanation-layer normalization so same-state
timeline records describe the changed substate, such as Docker image status or
command posture, instead of surfacing no-op wording like an `online` to
`online` transition.
Canonical relationship semantics stay in the backend correlation path. The
Patrol page renders no relationships; the resource drawer presents them in its
`Relationship map` through the shared `ResourceCorrelationSummary.tsx` card
and `frontend-modern/src/utils/resourceCorrelationPresentation.ts` helpers
rather than a drawer-local relationship formatter.
The backend Patrol and AI runtime summaries now also share
`internal/unifiedresources/change_presentation.go` for the canonical
change-kind and provenance mapping, so the same resource-model semantics
@@ -2257,11 +2248,13 @@ by the AI runtime prompt sections and Patrol seed context, so the change
wording itself stays canonical before the surrounding section headers are
applied.
The same helper now also owns the canonical recent-change section wrapper,
so the Patrol page and AI runtime can share the same heading and resource
so the AI runtime prompt sections, including the Patrol intelligence context
and Assistant handoff resource timelines, share the same heading and resource
prefix rules instead of rebuilding that section locally.
The canonical shared AI resource context now also surfaces policy routing and
redaction hints from unified resources, so the Patrol page and resource drawer
see the same governance posture that the runtime uses for export boundaries.
redaction hints from unified resources, so the runtime's export boundaries and
the resource drawer's governance rows read the same unified-resource policy
posture; the Patrol page renders no posture.
Patrol finding dismissal reasons and Patrol status labels now also route
through the shared frontend identifier-label helper, so the Patrol surfaces
do not keep their own underscore-stripping behavior separate from the rest
@@ -2720,6 +2720,24 @@ must remain distinguishable without hover. Workloads supplies this row metadata,
not local table CSS or a duplicate renderer; non-filesystem rows keep the compact
default. Verification covers the opt-in at this composition boundary and actual
long-path wrapping in the shared renderer's browser proof.
A guest's linked Pulse Agent can stop reporting while Proxmox keeps polling the
VM. `useWorkloads` copies `resource.agent.stale` onto the row as `agentStale`,
and sets `disksFromAgent` when Proxmox reported no guest filesystems and the
agent's own `disks` filled in. When that agent is stale, `GuestDrawerOverview.tsx`
reads the Guest RAID card as last known through the shared `RaidCard`
`lastKnownReason` prop with `HOST_AGENT_STOPPED_REPORTING_REASON`, puts a status
line over muted Network rows, reads agent-supplied Filesystems rows through the
existing `last-known` disk read state, and marks the Pulse Agent row as stopped
reporting. Agent-supplied filesystem rows follow the agent's freshness rather
than the Proxmox `diskStatusReason`, which describes Proxmox's own guest reads,
so a live agent's rows keep their usage and bars. Those rows read as
unavailable once the guest is stopped, whether its status or the Proxmox
`vm-stopped` reason says so, and Proxmox's own guest filesystems keep their
Proxmox read state. The two flags ride the existing row mapping and
add no query or poll; `GuestDrawer.test.tsx` and `useWorkloads.test.ts` pin
both sources. The workload table's disk cell and the drawer History still read
the aggregate disk metric and the Proxmox reason, and the agent's OS, addresses
and physical disk health carry no stale cue yet.
Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency
boundary for collapse: Workloads owns which inline row is selected and the
close handler, while
@@ -0,0 +1,37 @@
{
"version": 1,
"base_sha": "bab38a25b92684c978c4273200765152040624ed",
"verified_at": "2026-10-07T14:02:51Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
"frontend-modern/src/features/alerts/useAlertOverviewState.ts"
],
"content_sha256": {
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "bb98293f7a7b7b857b40ec71a38e1e32163aa937405278dd07af2a8c9dac1118",
"frontend-modern/src/features/alerts/useAlertOverviewState.ts": "3e2c91ba930a1d3faf3965a5fd68f5bb44e27465a88cbb06c23aabe500d25668"
},
"routes": [
"/alerts/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack (backend :7697, Vite :5217 serving this tree on base bab38a25b9) with Playwright page.clock installed before load and an init script recording when each setInterval starts, so the shared 30s clock and the overview hook's minute interval have known phases",
"Three producer-shaped copies of a live disk threshold alert (metricStatus breaching) sent in one websocket frame 15s after a shared tick and 45s after the hook's minute, then /ws data frames held (data-free pong per step) and /api reads stalled, so only the clock moves",
"Late-mounted card (raised 70s ago, reading 10m05s old) reads '1 min. ago' and 'Last reading: Disk 92%, 10 mins ago' at mount; held card (reading goes stale 5s after the hook's next minute) is live at mount and 1s before the cut-off, and reads 'Last reading: ..., 10 mins ago' 31s after it; Triggered (24h) drops 85 -> 84 31s after the third copy turns 24h old; no page errors; same at 1440 and 390",
"Same-session control with both runtime files swapped to main under the running Vite: the late card read 'this minute' and led with the old reading as live, the held card was still live 31s after the cut-off, and the 24h count rose 84 -> 85 because the just-raised copy read as future; fix restored and diff compared byte-for-byte",
"tsc clean; src/features/alerts, src/components/Alerts and src/utils suites pass except the Mac-only agentInstallCommand lifecycle tests"
],
"interactions": [
"Loaded /alerts/overview, injected the alert copies between ticks, held the stream and stepped the page clock past the stale cut-off and the 24h boundary, at both viewports, before and after the fix"
]
}
@@ -0,0 +1,47 @@
{
"version": 1,
"base_sha": "43a80dafd91e92d3079a039cdb08929281812eef",
"verified_at": "2026-10-07T13:06:25Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f984547722c304cd3c80610a3e5ef743b3e6ad268493016ddc649c858e54e6de",
"frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d",
"frontend-modern/src/types/workloads.ts": "4eebb6b801a056900e7fc309679cdca891b48bdd2672e40dbb58eaffce4ca0c6"
},
"routes": [
"/proxmox?q=analytics-batch-310",
"/proxmox?q=analytics-batch-118",
"/proxmox?q=analytics-batch-58",
"/proxmox?q=analytics-batch-262"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack built from origin/main 43a80dafd9 plus this change, with a local-only go build -overlay of internal/mock/generator.go: host agents apollo-114 and meridian-731 held past their reporting lease (offline, LastSeen frozen 14 minutes back, readings retained); all four standalone agents seeded with md0 raid1 clean and md1 raid5 'clean, degraded, recovering' at 42.4% / 118M/sec (sdd1 faulty, sde1 spare rebuilding); analytics-batch-310 and analytics-batch-58 forced to report no Proxmox guest filesystems with diskStatusReason agent-not-running",
"Agents linked into VMs through the real POST /api/resources/{vm}/link: apollo-114 -> analytics-batch-118, meridian-731 -> analytics-batch-310 (both silent), spectrum-709 -> analytics-batch-262 and meridian-848 -> analytics-batch-58 (both live). /api/resources serves agent.stale true with the agent's raid, disks and networkInterfaces on the two silent VMs and no stale flag on the two live ones",
"Before this change (same stack, PR 2652 head, real browser run): silent VMs showed Guest RAID with emerald 'clean', amber 'clean, degraded, recovering', 'Rebuild: 42% \u00b7 118M/sec' and coloured member badges, network rows and 'Pulse 0.2.0-alpha' as current; the live analytics-batch-58 showed its agent filesystems as 'Usage unavailable \u00b7 ?/328 GB' under 'Guest agent not running. Install and start qemu-guest-agent in the VM.'",
"After, silent analytics-batch-310 (agent filesystems) at 1440 and 390: Pulse Agent row 'Pulse 0.2.0-alpha \u00b7 stopped reporting' in amber with title 'Pulse Agent 0.2.0-alpha has stopped reporting. Its readings here are its last report.'; Filesystems status 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' with 'Last known 75%' / 'Last known 42%' rows and no bars; Network status 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' over a muted eth0 row; both RAID arrays data-raid-reading=last-known, '(last known)' states in muted text titled 'Last known reading, not current: host agent stopped reporting', 'Rebuild was at 42%', neutral badges 'sdd1 \u00b7 faulty' and 'sde1 \u00b7 spare rebuilding'",
"After, silent analytics-batch-118 with Proxmox guest filesystems (earlier run of the same change): RAID, network and agent row read as above while the Proxmox filesystem rows kept their usage and bars",
"After, live analytics-batch-58 (agent filesystems, Proxmox agent-not-running): '/ 65%' and '/data 28%' with progress bars, no status line, no qemu-guest-agent advice; RAID current with emerald/amber states and 'Rebuild: 42% \u00b7 118M/sec'",
"After, live control analytics-batch-262 (Proxmox filesystems): unchanged at both widths",
"No horizontal page overflow at either width; no page errors. Stopped guests were not exercised in the browser: their agent filesystems read 'Usage unavailable' under the stopped-VM message whether the guest status or the Proxmox vm-stopped reason says stopped, and the stale flip on an open drawer, are covered by GuestDrawer.test.tsx. The final stopped-status gate changes only stopped guests, so the running-guest states above were observed on this code path"
],
"interactions": [
"Opened each guest's inline drawer from the Proxmox workloads table filtered by name (name cell click at 1440, first visible cell tap at 390)",
"Scrolled the phone drawer to the Network section and Guest RAID card and captured both",
"Read computed text colour, titles and data-raid-reading of every Filesystems, Network, System and RAID row via Playwright"
]
}
@@ -0,0 +1,41 @@
{
"version": 1,
"base_sha": "156d2e274d9f14e8283ca3d9e64243fb6882672a",
"verified_at": "2026-10-07T14:04:29Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "68542124c9cd1742051332aa09fc9795ff93ba705522f59007f42dbbb4815d87"
},
"routes": [
"/patrol",
"/patrol/protection",
"/patrol/activity"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Tree = origin/main 156d2e274d plus this change; production vite build embedded in a go build binary, private DEMO_MODE mock stack on :7791 (demo/demo, PULSE_TELEMETRY=false), driven by Playwright",
"Runtime-neutral proof: before/after production builds of this tree give byte-identical dist output (all 187 files equal by shasum), so the removed exports were already tree-shaken; check-bundle-size.mjs passes",
"Patrol Inbox, Protection, Activity, Finding options and history (Open work), finding review panel, run findings snapshot ('What Patrol found during this run.') and selected run in Patrol history: none render 'Recent changes', 'Learned correlations', 'Policy coverage', 'policy-covered', 'Supporting context' or a Details context control, at both viewports",
"Assistant drawer after 'Explain with Assistant' shows the Pulse Patrol attention briefing naming the selected decision's resource (buildPatrolAttentionAssistantHandoff from the changed module); the demo instance refuses the chat reply itself ('Demo mode enabled'), which is expected read-only behaviour",
"Assistant drawer after the selected run's 'Discuss' shows the 'Patrol run attached' briefing (buildPatrolRunAssistantHandoff from the changed module)",
"No page errors; the only console error (desktop) is 'Failed to load intelligence summary: TypeError: Failed to fetch' paired with net::ERR_ABORTED /api/ai/intelligence, i.e. a read cancelled by the script's own page.goto between Patrol routes; CSP inline-style reports and demo-mode 403s are pre-existing"
],
"interactions": [
"Loaded /patrol, selected the first decision row and clicked 'Explain with Assistant'",
"Loaded /patrol/protection and waited for Protected outcomes to finish loading",
"Loaded /patrol/activity, expanded 'Finding options and history', opened 'Review issue' for the Docker host finding, switched to History, opened the newest run, returned to History and clicked the run's 'Discuss' button",
"Repeated every step at 1440x900 and 390x844"
]
}
@@ -1602,6 +1602,157 @@ describe('GuestDrawer', () => {
});
});
// ── In-guest Pulse Agent readings ──
describe('In-guest Pulse Agent readings', () => {
// Proxmox read no guest filesystems, so these rows came from the agent.
const makeAgentGuest = (overrides: Partial<WorkloadGuest> = {}) =>
makeGuest({
agentVersion: '6.4.5',
agentKind: 'pulse',
disksFromAgent: true,
diskStatusReason: 'agent-not-running',
disks: [{ total: 100, used: 75, free: 25, usage: 75, mountpoint: '/' }],
networkInterfaces: [{ name: 'eth0', addresses: ['192.168.1.5'], rxBytes: 1024 }],
agentRaid: [
{
device: '/dev/md1',
name: 'data',
level: 'raid5',
state: 'clean, degraded, recovering',
totalDevices: 3,
activeDevices: 2,
workingDevices: 3,
failedDevices: 1,
spareDevices: 0,
devices: [{ device: 'sdd1', state: 'faulty', slot: 1 }],
rebuildPercent: 42,
rebuildSpeed: '118M/sec',
},
],
...overrides,
});
it("reads a silent in-guest agent's RAID, network and filesystems as last known", () => {
render(() => <GuestDrawer guest={makeAgentGuest({ agentStale: true })} onClose={vi.fn()} />);
expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toHaveAttribute(
'title',
'Pulse Agent 6.4.5 has stopped reporting. Its readings here are its last report.',
);
expect(
technicalDetails().getByText(
'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
),
).toBeInTheDocument();
expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument();
expect(
technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }),
).toBeNull();
expect(
technicalDetails().getByText(
'Using last known interface details. The Pulse Agent in this guest stopped reporting.',
),
).toBeInTheDocument();
expect(
technicalDetails()
.getByText(/^192\.168\.1\.5/)
.closest('td'),
).toHaveClass('text-muted');
// Proxmox's guest-read reason does not describe rows the agent supplied.
expect(technicalDetails().queryByText(/qemu-guest-agent/)).toBeNull();
const array = screen.getByTestId('raid-card-array');
expect(array).toHaveAttribute('data-raid-reading', 'last-known');
expect(within(array).getByTestId('raid-card-state')).toHaveAttribute(
'title',
'Last known reading, not current: host agent stopped reporting',
);
expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent(
'Rebuild was at 42%',
);
});
it('keeps a live in-guest agent current when Proxmox has no guest reads', () => {
render(() => <GuestDrawer guest={makeAgentGuest()} onClose={vi.fn()} />);
expect(technicalDetails().getByText('Pulse 6.4.5')).toBeInTheDocument();
expect(
technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }),
).toHaveAttribute('aria-valuenow', '75');
expect(technicalDetails().queryByText(/Last known|qemu-guest-agent/)).toBeNull();
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
expect(screen.getByTestId('raid-card-rebuild')).toHaveTextContent('Rebuild: 42% · 118M/sec');
});
it("keeps Proxmox's own guest filesystems current under a silent agent", () => {
render(() => (
<GuestDrawer
guest={makeAgentGuest({
agentStale: true,
disksFromAgent: undefined,
diskStatusReason: undefined,
})}
onClose={vi.fn()}
/>
));
expect(
technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }),
).toHaveAttribute('aria-valuenow', '75');
expect(technicalDetails().queryByText(/Using last known disk stats/)).toBeNull();
expect(screen.getByTestId('raid-card-array')).toHaveAttribute(
'data-raid-reading',
'last-known',
);
});
// Proxmox gives vm-stopped only for a guest with allocated disk, so the
// guest's own stopped status must hold the rows too.
it.each([
['with the Proxmox reason', 'vm-stopped'],
['without a Proxmox reason', undefined],
])(
"reads a stopped guest's agent filesystems as unavailable before the lease lapses, %s",
(_label, diskStatusReason) => {
render(() => (
<GuestDrawer
guest={makeAgentGuest({ status: 'stopped', diskStatusReason })}
onClose={vi.fn()}
/>
));
expect(
technicalDetails().getByText(
'Guest filesystem stats unavailable while the VM is stopped.',
),
).toBeInTheDocument();
expect(technicalDetails().getByText(/^Usage unavailable/)).toBeInTheDocument();
expect(
technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }),
).toBeNull();
},
);
it('follows the agent between silent and reporting while the drawer stays open', () => {
const [guest, setGuest] = createSignal(makeAgentGuest());
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
setGuest({ ...guest(), agentStale: true });
expect(screen.getByTestId('raid-card-array')).toHaveAttribute(
'data-raid-reading',
'last-known',
);
expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toBeInTheDocument();
expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument();
setGuest({ ...guest(), agentStale: undefined });
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
expect(technicalDetails().queryByText(/stopped reporting|Last known/)).toBeNull();
});
});
// ── WebInterfaceUrlField ──
describe('WebInterfaceUrlField', () => {
@@ -10,6 +10,7 @@ import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDis
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import type { Alert } from '@/types/api';
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
import { HOST_AGENT_STOPPED_REPORTING_REASON } from '@/components/Infrastructure/resourceDetailMappers';
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
import { formatBytes } from '@/utils/format';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
@@ -79,6 +80,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
ageClass: props.backupPresentation?.ageClass,
});
const guestReadPresentation = () => getGuestDrawerGuestReadPresentation(props.guest);
// A linked Pulse agent past its reporting lease leaves its last report on
// the guest, so its RAID, network and filesystem rows read as last known.
const agentLastKnownReason = () =>
props.guest.agentStale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined;
const coverageLabel = () => {
if (props.hasWorkloadActionAgent) return WORKLOAD_ACTION_AGENT_LABEL;
if (props.hasAgentInfo) return `${props.agentHeading} observed`;
@@ -147,9 +152,22 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
props.guest.vmware?.datacenterName ||
props.guest.vmware?.clusterName,
);
const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
const diskReadState =
props.guest.telemetryAvailability?.disk === false
// Rows the linked Pulse agent supplied follow that agent's freshness, not
// the Proxmox reason for its own guest reads. A stopped guest has no
// current filesystems from either source, with or without the Proxmox
// vm-stopped reason, which it gives only for a guest with allocated disk.
const vmDiskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
const agentDisks = props.guest.disksFromAgent === true;
const agentDisksStopped =
agentDisks && (props.guest.status === 'stopped' || vmDiskReason === 'vm-stopped');
const diskReason = agentDisks ? undefined : vmDiskReason;
const diskReadState = agentDisks
? agentDisksStopped
? 'unavailable'
: agentLastKnownReason()
? 'last-known'
: 'current'
: props.guest.telemetryAvailability?.disk === false
? 'unavailable'
: diskReason
? diskReason.startsWith('prev-')
@@ -159,11 +177,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState),
);
const diskStatusMessage = diskReason
? getWorkloadGuestDiskStatusMessage(diskReason)
: diskReadState === 'unavailable'
? 'Filesystem usage is unavailable.'
: null;
const diskStatusMessage = agentDisks
? agentDisksStopped
? isGuestDrawerVM(props.guest)
? getWorkloadGuestDiskStatusMessage('vm-stopped')
: 'Filesystem usage is unavailable.'
: agentLastKnownReason()
? 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.'
: null
: diskReason
? getWorkloadGuestDiskStatusMessage(diskReason)
: diskReadState === 'unavailable'
? 'Filesystem usage is unavailable.'
: null;
return compactDetailSections([
discovery
@@ -186,7 +212,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
rows: compactDetailRows([
props.guest.cpus ? makeDetailRow('CPUs', `${props.guest.cpus}`) : null,
props.hasAgentInfo
? makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
? agentLastKnownReason()
? makeDetailRow(props.agentHeading, `${props.agentLabel} · stopped reporting`, {
title: `${props.agentTitle} has stopped reporting. Its readings here are its last report.`,
tone: 'warning',
wrap: true,
})
: makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
: null,
]),
},
@@ -272,8 +304,16 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
props.hasNetworkInterfaces
? {
label: 'Network',
rows: compactDetailRows(
props.networkInterfaces.slice(0, 4).map((iface, index) => {
rows: compactDetailRows([
// Only the linked Pulse agent reports guest interfaces.
makeDetailRow(
'Status',
agentLastKnownReason()
? 'Using last known interface details. The Pulse Agent in this guest stopped reporting.'
: null,
{ layout: 'stacked', wrap: true },
),
...props.networkInterfaces.slice(0, 4).map((iface, index) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
const detail = [
@@ -291,11 +331,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
iface.name || `Interface ${index + 1}`,
detail || 'No details',
{
tone: agentLastKnownReason() ? 'muted' : undefined,
wrap: true,
},
);
}),
),
]),
}
: null,
]);
@@ -338,7 +379,11 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
</Suspense>
</Show>
<Show when={props.guest.agentRaid?.length}>
<RaidCard arrays={props.guest.agentRaid} title="Guest RAID" />
<RaidCard
arrays={props.guest.agentRaid}
title="Guest RAID"
lastKnownReason={agentLastKnownReason()}
/>
</Show>
<div class="space-y-3">
@@ -36,6 +36,7 @@ import { AlertSnoozeAction } from './AlertSnoozeAction';
import { isAlertSnoozed } from './useAlertSnoozeState';
import { isPulseSystemAlert } from '@/utils/alertScope';
import { formatTemperature } from '@/utils/temperature';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import {
normalizeSourcePlatformQueryValue,
resolvePlatformTypeFromSources,
@@ -110,6 +111,9 @@ const platformPagePath = (platform: string | undefined): string | undefined => {
};
export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
// The started age and the live reading's stale cut-off and ages measure
// from the wall clock, so a card that mounts between ticks is current.
const now = useRelativeTimeNow();
const alertKey = () => getCanonicalAlertId(props.alert);
const hasResource = () =>
!isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim());
@@ -138,9 +142,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
// A threshold alert can stay open below its trigger, so the card leads with
// the reading Pulse is evaluating now; the message keeps the last breach.
const metricPresentation = () =>
isPulseSystemAlert(props.alert)
? null
: getMetricAlertPresentation(props.alert, props.state.tick());
isPulseSystemAlert(props.alert) ? null : getMetricAlertPresentation(props.alert, now());
const alertLevels = (): { alert: string; clear?: string } | null => {
const presentation = metricPresentation();
if (presentation) {
@@ -318,7 +320,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
new Date(props.alert.startTime).toLocaleString(),
)}
>
{formatAlertOverviewStartedAgo(props.alert.startTime, props.state.tick())}
{formatAlertOverviewStartedAgo(props.alert.startTime, now())}
</span>
</div>
<Show
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n';
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
import { METRIC_ALERT_STATUS_STALE_MS } from '../metricAlertPresentation';
vi.mock('@solidjs/router', () => ({
useLocation: () => ({ hash: '', pathname: '/alerts', search: '', query: {} }),
A: (props: Record<string, unknown>) => props.children,
}));
const getDeliveryDiagnoses = vi.fn<() => Promise<AlertDeliveryDiagnosis[]>>();
vi.mock('@/api/alerts', () => ({
AlertsAPI: {
get getDeliveryDiagnoses() {
return getDeliveryDiagnoses;
},
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: { success: vi.fn(), error: vi.fn() },
}));
vi.mock('@/utils/logger', () => ({
logger: { error: vi.fn() },
}));
vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({
InvestigateAlertButton: () => null,
}));
import { OverviewTab } from '../OverviewTab';
const OPENED_AT = Date.parse('2026-10-07T12:00:00Z');
// Between the shared clock's first and second ticks, and before the overview's
// own minute.
const BETWEEN_TICKS_MS = RELATIVE_TIME_TICK_MS + 15_000;
function cpuAlert(id: string, startTime: number, observedAt: number): Alert {
return {
id,
resourceId: `vm-${id}`,
resourceName: `VM ${id}`,
type: 'cpu',
level: 'warning',
message: `CPU on VM ${id} at 92%`,
value: 92,
threshold: 80,
startTime: new Date(startTime).toISOString(),
lastSeen: new Date(observedAt).toISOString(),
acknowledged: false,
node: 'node1',
metricStatus: {
phase: 'breaching',
value: 91,
unit: '%',
observedAt: new Date(observedAt).toISOString(),
trigger: 80,
recovery: 75,
},
} as Alert;
}
function renderOverview() {
const [activeAlerts, setActiveAlerts] = createSignal<Record<string, Alert>>({});
render(() => (
<OverviewTab
overrides={[]}
activeAlerts={activeAlerts()}
updateAlert={vi.fn()}
showQuickTip={() => false}
dismissQuickTip={vi.fn()}
showAcknowledged={() => true}
setShowAcknowledged={vi.fn()}
alertsDisabled={() => false}
/>
));
return setActiveAlerts;
}
const triggered24h = () =>
screen
.getByText('Triggered (24h)')
.closest('[data-alert-overview-stat]')
?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent;
describe('Alert overview card clock', () => {
beforeEach(() => {
setActiveLocale(DEFAULT_LOCALE);
getDeliveryDiagnoses.mockReset();
getDeliveryDiagnoses.mockResolvedValue([]);
vi.useFakeTimers();
vi.setSystemTime(OPENED_AT);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('measures a card that mounts between ticks from the wall clock', () => {
const setActiveAlerts = renderOverview();
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
// Raised 70 seconds ago; last evaluated just past the stale cut-off. Read
// from the time of the last tick, the card would call it "this minute"
// and lead with the old reading as the live one.
const now = Date.now();
setActiveAlerts({
late: cpuAlert('late', now - 70_000, now - METRIC_ALERT_STATUS_STALE_MS - 5_000),
});
expect(screen.getByText('1 min. ago')).toBeInTheDocument();
expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument();
});
it('turns stale at the next shared tick after the cut-off with no data change', () => {
const setActiveAlerts = renderOverview();
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
// The reading goes stale 5 seconds after the shared clock's second tick.
const staleAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000;
const now = Date.now();
setActiveAlerts({ held: cpuAlert('held', now, staleAt - METRIC_ALERT_STATUS_STALE_MS) });
expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument();
vi.advanceTimersByTime(OPENED_AT + 2 * RELATIVE_TIME_TICK_MS - Date.now());
expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument();
// The next tick lands within one shared tick of the cut-off, not on the
// overview's next minute.
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(screen.queryByText('CPU 91%, above the 80% alert level')).toBeNull();
expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument();
});
it('drops an alert from the 24h count at the next shared tick after it turns 24h old', () => {
const setActiveAlerts = renderOverview();
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
const leavesWindowAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000;
setActiveAlerts({ old: cpuAlert('old', leavesWindowAt - 86_400_000, Date.now()) });
expect(triggered24h()).toBe('1');
vi.advanceTimersByTime(OPENED_AT + 3 * RELATIVE_TIME_TICK_MS - Date.now());
expect(triggered24h()).toBe('0');
});
});
@@ -26,7 +26,6 @@ import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard';
const acknowledge = vi.fn();
const toggleTimeline = vi.fn();
const state = {
tick: () => Date.now(),
processingAlerts: () => new Set(),
snoozeProcessingAlerts: () => new Set(),
deliveryDiagnoses: () => ({}),
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AlertsAPI } from '@/api/alerts';
import { notificationStore } from '@/stores/notifications';
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
import { useAlertOverviewState } from '../useAlertOverviewState';
@@ -118,6 +119,22 @@ describe('useAlertOverviewState', () => {
expect(result.deliveryDiagnoses()).toEqual({ a1: retained });
});
it('refreshes diagnoses once a minute, not on the 30-second age clock', async () => {
renderHook(() =>
useAlertOverviewState({
activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }),
overrides: () => [],
showAcknowledged: () => true,
updateAlert: vi.fn(),
}),
);
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS);
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(60_000 - RELATIVE_TIME_TICK_MS);
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledTimes(2);
});
it('ignores pending responses and stops periodic reads after disposal', async () => {
let finish!: (value: AlertDeliveryDiagnosis[]) => void;
vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce(
@@ -3,6 +3,7 @@ import type { Accessor } from 'solid-js';
import { AlertsAPI } from '@/api/alerts';
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import type { Override } from './types';
import { useAlertAcknowledgementState } from './useAlertAcknowledgementState';
import { useAlertSnoozeState } from './useAlertSnoozeState';
@@ -34,8 +35,7 @@ export interface UseAlertOverviewStateProps {
}
export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
const [tick, setTick] = createSignal(Date.now());
const tickInterval = setInterval(() => setTick(Date.now()), 60_000);
const now = useRelativeTimeNow();
const activeAlerts = createMemo(() => Object.values(props.activeAlerts()));
const {
unacknowledgedAlerts,
@@ -56,10 +56,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
handleUnsnooze,
} = useAlertSnoozeState({ alerts: activeAlerts, updateAlert: props.updateAlert });
onCleanup(() => {
clearInterval(tickInterval);
});
// Delivery diagnoses answer "did/will this alert notify?" per card. One
// bulk request covers every active alert; a failed refresh keeps the last
// snapshot and the card simply shows no delivery line for unknown alerts.
@@ -98,19 +94,24 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
.join('\n'),
);
createEffect(() => {
// Refresh when the active alert set changes and on the shared minute
// tick, so time-based holds (cooldown, quiet hours) stay current.
// Refresh when the active alert set changes.
activeAlertIdsKey();
tick();
void refreshDeliveryDiagnoses();
});
// And once a minute, so time-based holds (cooldown, quiet hours) stay
// current. This is a server read, so it keeps its own minute rather than
// following the 30-second clock the ages read.
const diagnosisInterval = setInterval(() => void refreshDeliveryDiagnoses(), 60_000);
onCleanup(() => {
clearInterval(diagnosisInterval);
});
const alertStats = createMemo(() => {
const alerts = effectiveAlerts();
const recent = alerts.filter((alert) => {
const ts = new Date(alert.startTime).getTime();
if (Number.isNaN(ts)) return true;
const age = tick() - ts;
const age = now() - ts;
return age >= 0 && age < 86_400_000;
});
const unacknowledged = alerts.filter((alert) => !alert.acknowledged);
@@ -169,7 +170,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
});
return {
tick,
alertStats,
filteredAlerts,
groupedAlerts,
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { ApprovalRequest, InvestigationRecord } from '@/api/ai';
import type { ResourceCorrelation } from '@/types/aiIntelligence';
import type { PatrolRunRecord } from '@/api/patrol';
import type { UnifiedFinding } from '@/stores/aiIntelligence';
@@ -14,7 +13,6 @@ import {
buildPatrolAssistantProposedFixBriefingInput,
buildPatrolAssistantProposedFixBriefingInputFromApproval,
buildPatrolConfigurationFailureHandoff,
buildPatrolInvestigationContextSummary,
buildPatrolInvestigationRecordPresentation,
buildPatrolRunAssistantHandoff,
} from '../patrolInvestigationContextModel';
@@ -25,72 +23,6 @@ import {
// arms) identified via V8 branch coverage. They do not duplicate the happy
// paths already pinned by the dev test.
describe('buildPatrolInvestigationContextSummary (branch coverage)', () => {
it('uses the plural recent-change and singular policy-resource wording', () => {
expect(
buildPatrolInvestigationContextSummary({
recentChangesCount: 2,
correlations: null,
policyPosture: {
total_resources: 1,
sensitivity_counts: {},
routing_counts: {},
},
}),
).toEqual({
recentChangeCount: 2,
correlationCount: 0,
governedResourceCount: 1,
hasContext: true,
summaryText: '2 recent changes · 1 policy-covered resource',
});
});
it('clamps non-finite and negative counts to zero with no context parts', () => {
expect(
buildPatrolInvestigationContextSummary({
recentChangesCount: Number.NEGATIVE_INFINITY,
correlations: { count: -3, correlations: [] },
policyPosture: {
total_resources: Number.NaN,
sensitivity_counts: {},
routing_counts: {},
},
}),
).toEqual({
recentChangeCount: 0,
correlationCount: 0,
governedResourceCount: 0,
hasContext: false,
summaryText: '',
});
});
it('clamps a non-finite correlation count to the list length fallback', () => {
expect(
buildPatrolInvestigationContextSummary({
correlations: {
count: Number.POSITIVE_INFINITY,
correlations: [
{
source_id: 's',
target_id: 't',
event_pattern: 'p',
occurrences: 1,
} as unknown as ResourceCorrelation,
],
},
}),
).toEqual({
recentChangeCount: 0,
correlationCount: 1,
governedResourceCount: 0,
hasContext: true,
summaryText: '1 correlation',
});
});
});
describe('buildPatrolInvestigationRecordPresentation (branch coverage)', () => {
it('returns the empty presentation for null and undefined records', () => {
const empty = {
@@ -11,13 +11,11 @@ import {
buildPatrolAssistantFindingHandoff,
buildPatrolAssistantFindingHandoffActions,
buildPatrolConfigurationFailureHandoff,
buildPatrolInvestigationContextSummary,
buildPatrolInvestigationRecordPresentation,
buildPatrolRemediationPlanAssistantBriefing,
buildPatrolRemediationPlanAssistantModelContext,
buildPatrolRunAssistantHandoff,
patrolAssistantFindingHandoffRequiresApprovalMode,
selectPatrolSupportingRecentChanges,
} from '../patrolInvestigationContextModel';
// Second branch-coverage companion to patrolInvestigationContextModel.test.ts.
@@ -35,122 +33,6 @@ const minimalRecord = {
status: 'completed',
} as unknown as InvestigationRecord;
describe('buildPatrolInvestigationContextSummary (residual branches)', () => {
it('returns zero correlations when the response is present but has no count and no array', () => {
// normalizeCorrelationCount final `return 0` arm: object that is neither a
// finite numeric count nor an Array `correlations` field.
const summary = buildPatrolInvestigationContextSummary({
correlations: { unexpected: 'shape' } as unknown as
import('@/types/aiIntelligence').CorrelationsResponse | null,
});
expect(summary).toMatchObject({
correlationCount: 0,
hasContext: false,
summaryText: '',
});
});
});
describe('selectPatrolSupportingRecentChanges (same-state transition branches)', () => {
it('uses a custom reason and skips non-array changedFields', () => {
// formatSameStateTransitionReason: changedFieldLabels empty (metadata
// present but changedFields not an array) -> falls through to the reason
// arm, and a reason that is not the default yields "${reason} while ${state}".
const [change] = selectPatrolSupportingRecentChanges([
{
id: 'c-reason',
observedAt: '2026-01-01T00:00:00Z',
resourceId: 'vm-1',
kind: 'state_transition',
from: 'online',
to: 'online',
sourceType: 'pulse_diff',
confidence: 'high',
reason: 'operator-initiated restart',
metadata: { note: 'not an array' },
},
]);
expect(change).toMatchObject({
from: undefined,
to: undefined,
reason: 'operator-initiated restart while online',
});
});
it('skips blank changed-field entries and falls back to the identifier label for unknown fields', () => {
const [change] = selectPatrolSupportingRecentChanges([
{
id: 'c-fields',
observedAt: '2026-01-01T00:00:00Z',
resourceId: 'vm-1',
kind: 'restart',
from: 'on',
to: 'on',
sourceType: 'pulse_diff',
confidence: 'high',
metadata: { changedFields: ['', ' ', 'custom_field', 'tags'] },
},
]);
// Blank entries are skipped; 'custom_field' uses formatIdentifierLabel
// fallback ("Custom Field"); 'tags' resolves to the known map label.
expect(change.reason).toBe('Custom Field and tags changed while on');
});
it('renders the single-field and three-or-more-field compact label lists', () => {
const [single] = selectPatrolSupportingRecentChanges([
{
id: 'c-one',
observedAt: '2026-01-01T00:00:00Z',
resourceId: 'vm-1',
kind: 'state_transition',
from: 'online',
to: 'online',
sourceType: 'pulse_diff',
confidence: 'high',
metadata: { changedFields: ['tags'] },
},
]);
expect(single.reason).toBe('tags changed while online');
const [many] = selectPatrolSupportingRecentChanges([
{
id: 'c-many',
observedAt: '2026-01-01T00:00:00Z',
resourceId: 'vm-1',
kind: 'state_transition',
from: 'online',
to: 'online',
sourceType: 'pulse_diff',
confidence: 'high',
metadata: { changedFields: ['status', 'incidents', 'parentId'] },
},
]);
expect(many.reason).toBe('status, incident state, and 1 more changed while online');
});
it('falls back to the default wording when the reason is exactly the no-op default', () => {
const [change] = selectPatrolSupportingRecentChanges([
{
id: 'c-default',
observedAt: '2026-01-01T00:00:00Z',
resourceId: 'vm-1',
kind: 'state_transition',
from: 'online',
to: 'online',
sourceType: 'pulse_diff',
confidence: 'high',
reason: 'Resource state changed',
},
]);
// Case-insensitive match against the default -> generic fallback wording.
expect(change.reason).toBe('state details changed while online');
});
});
describe('buildPatrolInvestigationRecordPresentation (residual branches)', () => {
it('labels an unknown tool via the identifier fallback', () => {
const presentation = buildPatrolInvestigationRecordPresentation({
@@ -12,120 +12,14 @@ import {
buildPatrolAssistantFindingHandoffActions,
buildPatrolAssistantProposedFixBriefingInput,
buildPatrolConfigurationFailureHandoff,
buildPatrolInvestigationContextSummary,
buildPatrolRunAssistantHandoff,
buildPatrolInvestigationRecordPresentation,
buildPatrolRemediationPlanAssistantBriefing,
buildPatrolRemediationPlanAssistantModelContext,
patrolAssistantFindingHandoffRequiresApprovalMode,
selectPatrolSupportingRecentChanges,
} from '../patrolInvestigationContextModel';
describe('patrolInvestigationContextModel', () => {
it('builds the canonical investigation context summary from recent changes, correlations, and policy posture', () => {
expect(
buildPatrolInvestigationContextSummary({
recentChangesCount: 1,
correlations: {
count: 2,
correlations: [],
},
policyPosture: {
total_resources: 4,
sensitivity_counts: {},
routing_counts: {},
},
}),
).toEqual({
recentChangeCount: 1,
correlationCount: 2,
governedResourceCount: 4,
hasContext: true,
summaryText: '1 recent change · 2 correlations · 4 policy-covered resources',
});
});
it('falls back to correlation list length and suppresses empty context parts', () => {
expect(
buildPatrolInvestigationContextSummary({
recentChangesCount: 0,
correlations: {
correlations: [
{
source_id: 'a',
source_name: 'A',
source_type: 'host',
target_id: 'b',
target_name: 'B',
target_type: 'vm',
event_pattern: 'cpu -> restart',
occurrences: 1,
avg_delay: 30,
confidence: 0.7,
last_seen: '2026-03-01T00:00:00Z',
description: 'desc',
},
],
count: Number.NaN,
},
policyPosture: {
total_resources: 0,
sensitivity_counts: {},
routing_counts: {},
},
}),
).toEqual({
recentChangeCount: 0,
correlationCount: 1,
governedResourceCount: 0,
hasContext: true,
summaryText: '1 correlation',
});
});
it('returns an empty context summary when no secondary investigation signals exist', () => {
expect(
buildPatrolInvestigationContextSummary({
recentChangesCount: undefined,
correlations: null,
policyPosture: null,
}),
).toEqual({
recentChangeCount: 0,
correlationCount: 0,
governedResourceCount: 0,
hasContext: false,
summaryText: '',
});
});
it('normalizes same-state recent changes before Patrol uses them as Assistant context', () => {
const changes = selectPatrolSupportingRecentChanges([
{
id: 'change-1',
observedAt: '2026-05-06T12:08:00Z',
resourceId: 'app-container-1',
kind: 'state_transition',
from: 'online',
to: 'online',
sourceType: 'pulse_diff',
sourceAdapter: 'docker_adapter',
confidence: 'high',
reason: 'resource state changed',
metadata: {
changedFields: ['docker.command'],
},
},
]);
expect(changes).toHaveLength(1);
expect(changes[0]).toMatchObject({
from: undefined,
to: undefined,
reason: 'Docker command changed while online',
});
});
it('keeps same-state recent changes out of Patrol Assistant no-op wording', () => {
const handoff = buildPatrolAssessmentAssistantHandoff({
assessment: {
@@ -1,8 +1,4 @@
import type {
CorrelationsResponse,
IntelligencePolicyPostureSummary,
ResourceCorrelation,
} from '@/types/aiIntelligence';
import type { ResourceCorrelation } from '@/types/aiIntelligence';
import type { ApprovalRequest, InvestigationRecord, RemediationPlan } from '@/api/ai';
import type { PatrolRunRecord } from '@/api/patrol';
import type { AttentionItemDetail } from '@/api/patrolAttention';
@@ -57,12 +53,6 @@ function formatBriefingTimestamp(value: string | undefined): string {
return formatRelativeTime(value, { compact: false, emptyText: '' });
}
export interface PatrolInvestigationContextSummaryInput {
recentChangesCount?: number | null;
correlations?: CorrelationsResponse | null;
policyPosture?: IntelligencePolicyPostureSummary | null;
}
export interface PatrolInvestigationContextSummary {
recentChangeCount: number;
correlationCount: number;
@@ -314,41 +304,6 @@ const SAME_STATE_CHANGED_FIELD_LABELS: Record<string, string> = {
identity: 'identity',
};
export function buildPatrolInvestigationContextSummary(
input: PatrolInvestigationContextSummaryInput,
): PatrolInvestigationContextSummary {
const recentChangeCount = normalizeNonNegativeCount(input.recentChangesCount);
const correlationCount = normalizeCorrelationCount(input.correlations);
const governedResourceCount = normalizeNonNegativeCount(input.policyPosture?.total_resources);
const parts: string[] = [];
if (recentChangeCount > 0) {
parts.push(`${recentChangeCount} recent change${recentChangeCount === 1 ? '' : 's'}`);
}
if (correlationCount > 0) {
parts.push(`${correlationCount} correlation${correlationCount === 1 ? '' : 's'}`);
}
if (governedResourceCount > 0) {
parts.push(
`${governedResourceCount} policy-covered resource${governedResourceCount === 1 ? '' : 's'}`,
);
}
return {
recentChangeCount,
correlationCount,
governedResourceCount,
hasContext: parts.length > 0,
summaryText: parts.join(' · '),
};
}
export function selectPatrolSupportingRecentChanges(
changes?: ResourceChange[] | null,
): ResourceChange[] {
return normalizeAssessmentRecentChanges(changes);
}
export function buildPatrolInvestigationRecordPresentation(
record?: InvestigationRecord | null,
): PatrolInvestigationRecordPresentation {
@@ -2254,17 +2209,6 @@ function normalizeApprovalBriefing(
};
}
function normalizeCorrelationCount(correlations?: CorrelationsResponse | null): number {
if (!correlations) return 0;
if (typeof correlations.count === 'number' && Number.isFinite(correlations.count)) {
return Math.max(0, Math.trunc(correlations.count));
}
if (Array.isArray(correlations.correlations)) {
return correlations.correlations.length;
}
return 0;
}
function normalizeNonNegativeCount(value?: number | null): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
@@ -142,6 +142,54 @@ describe('useWorkloads', () => {
}
});
it('marks a silent linked agent and the guest filesystems it supplied', async () => {
const disk = (mountpoint: string) => ({
mountpoint,
type: 'ext4',
total: 100,
used: 40,
free: 60,
usage: 40,
});
const [snapshot] = createSignal([
// Proxmox read no guest filesystems, so the agent's own report fills in.
{
...sampleResource,
id: 'vm-agent-disks',
vmid: 102,
sources: ['proxmox', 'agent'],
agent: { agentVersion: '6.4.5', stale: true, disks: [disk('/agent')] },
},
// Proxmox guest filesystems win over a live agent's.
{
...sampleResource,
id: 'vm-platform-disks',
vmid: 103,
sources: ['proxmox', 'agent'],
proxmox: { disks: [disk('/platform')] },
agent: { agentVersion: '6.4.5', stale: false, disks: [disk('/agent')] },
},
] as any);
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']> | undefined;
createRoot((d) => {
dispose = d;
result = useWorkloads(() => true, { resourceSnapshot: snapshot });
});
try {
await flushAsync();
const byId = (id: string) =>
result!.workloads().find((workload) => workload.canonicalResourceId === id);
expect(byId('vm-agent-disks')).toMatchObject({ agentStale: true, disksFromAgent: true });
expect(byId('vm-agent-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/agent']);
expect(byId('vm-platform-disks')?.agentStale).toBeUndefined();
expect(byId('vm-platform-disks')?.disksFromAgent).toBeUndefined();
expect(byId('vm-platform-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/platform']);
} finally {
dispose();
}
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -146,6 +146,7 @@ type APIResource = {
networkInterfaces?: APINetworkInterface[];
disks?: APIDiskInfo[];
raid?: HostRAIDArray[];
stale?: boolean;
};
docker?: {
containerId?: string;
@@ -592,6 +593,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
})(),
disk: buildMetric(resource.metrics?.disk),
disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks),
disksFromAgent: (resource.proxmox?.disks == null && resource.agent?.disks != null) || undefined,
// Both the API and owning platform snapshot pass through this mapper.
// A retained numeric metric must not lose the provider's read deferral.
diskStatusReason: resource.proxmox?.diskStatusReason,
@@ -612,6 +614,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
agentId: resource.agent?.agentId,
agentRaid: resource.agent?.raid,
networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces),
// A linked agent past its reporting lease keeps its last report on the
// resource, so its RAID, network and filesystem rows are not current.
agentStale: resource.agent?.stale === true || undefined,
networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0,
networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0,
diskRead: finiteMetricNumber(resource.metrics?.diskRead?.value) ?? 0,
+10
View File
@@ -60,6 +60,16 @@ export type WorkloadGuest = (VM | Container) & {
agentId?: string;
/** RAID arrays reported by the linked Pulse agent, not Proxmox virtual disks. */
agentRaid?: HostRAIDArray[];
/**
* Set when the linked Pulse agent is past its reporting lease. Its RAID,
* network and filesystem readings are then its last report, not current.
*/
agentStale?: boolean;
/**
* Set when `disks` came from the linked Pulse agent because the platform
* reported no guest filesystems, so the rows follow that agent's freshness.
*/
disksFromAgent?: boolean;
updateStatus?: DockerContainerUpdateStatus;
// Server-evaluated capability refusals from the unified resource. The
// update button reads this to disable itself with the refusal reason
@@ -3610,15 +3610,14 @@ describe('frontend resource type boundaries', () => {
// Verify the symbol is imported from the model without prescribing a
// specific single- vs multi-line import shape; usePatrolIntelligenceState
// groups several patrolInvestigationContextModel imports together.
expect(patrolIntelligenceStateSource).toContain(
'buildPatrolAssistantFindingHandoffFromUnifiedFinding',
expect(patrolIntelligenceStateSource).toMatch(
/import\s*\{[^}]*\bbuildPatrolAssistantFindingHandoffFromUnifiedFinding\b[^}]*\}\s*from '\.\/patrolInvestigationContextModel';/,
);
expect(patrolIntelligenceStateSource).toContain('buildPatrolSettingsReadinessFailure');
expect(patrolIntelligenceStateSource).toContain("from './patrolInvestigationContextModel'");
expect(patrolIntelligenceStateSource).not.toContain('recent_changes?.length');
expect(patrolIntelligenceStateSource).not.toContain('governed resource${');
expect(patrolInvestigationContextModelSource).toContain(
'export function buildPatrolInvestigationContextSummary',
'export function buildPatrolAssistantFindingHandoffFromUnifiedFinding',
);
expect(patrolInvestigationContextModelSource).toContain(
'export function buildPatrolInvestigationRecordPresentation',
@@ -3637,7 +3636,9 @@ describe('frontend resource type boundaries', () => {
expect(patrolInvestigationContextModelSource).not.toContain(
'record.proposed_fix.commands.join',
);
expect(patrolInvestigationContextModelSource).toContain('policy-covered resource');
// Patrol renders no supporting-context summary, so the model must not
// carry its policy-coverage count copy either.
expect(patrolInvestigationContextModelSource).not.toContain('policy-covered resource');
expect(patrolIntelligenceHeaderSource).not.toContain('buildPatrolScheduleOptions');
expect(patrolIntelligenceHeaderSource).not.toContain('getAIQuickstartCreditsPresentation');
expect(patrolIntelligenceHeaderSource).not.toContain('quickstart_credits');
+10
View File
@@ -4,6 +4,7 @@ import (
"strings"
"time"
alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config"
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
@@ -264,6 +265,15 @@ func shouldPreserveAlertOutsideNodeCleanup(alertID string, alert *Alert) bool {
if alert.CanonicalKind == string(alertspecs.AlertSpecKindProviderIncident) {
return true
}
// Kubernetes, TrueNAS and vSphere metric alerts carry a display label such
// as "TrueNAS Disk" or "Kubernetes Pod" in resourceType, which the platform
// words below never matched, and a cluster or host in Node rather than a
// Proxmox node. Every Proxmox poll used to delete them silently, and the
// next evaluation raised them again as new alerts.
if metadataStringValue(alert.Metadata, alertPlatformTypeKey) != "" ||
isUnifiedModernPlatformAlertType(alertconfig.CanonicalAlertResourceType(alertMetadataResourceType(alert))) {
return true
}
switch alertMetadataResourceType(alert) {
case string(unifiedresources.ResourceTypeAgent),
+76
View File
@@ -12628,6 +12628,82 @@ func TestCleanupAlertsForNodes(t *testing.T) {
})
}
// TestCleanupAlertsForNodesKeepsPlatformMetricAlerts pins that the Proxmox
// node cleanup, which runs on every Proxmox poll, leaves Kubernetes, TrueNAS
// and vSphere metric alerts alone. Their Node is a cluster or platform host,
// never a Proxmox node, and their resourceType is a display label.
func TestCleanupAlertsForNodesKeepsPlatformMetricAlerts(t *testing.T) {
m := newTestManager(t)
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
inputs := []*UnifiedResourceInput{
{ID: "physical-disk:truenas-main/sda", Type: "truenas-disk", Name: "sda", Node: "truenas-main", Instance: "TrueNAS", Temperature: &UnifiedResourceMetric{Value: 80, Percent: 80}},
{ID: "storage:truenas-main/pool:tank", Type: "truenas-pool", Name: "tank", Node: "truenas-main", Instance: "TrueNAS", Disk: &UnifiedResourceMetric{Percent: 95}},
{ID: "k8s:prod:pod:api", Type: "pod", Name: "api", Node: "k8s-node-1", Instance: "prod", CPU: &UnifiedResourceMetric{Percent: 99}},
{ID: "vmware:vc:vm:app-01", Type: "vmware-vm", Name: "app-01", Node: "esxi-01", Instance: "vc", CPU: &UnifiedResourceMetric{Percent: 99}},
}
for _, input := range inputs {
m.CheckUnifiedResource(input)
}
platformAlerts := alertKeys(m)
if len(platformAlerts) != len(inputs) {
t.Fatalf("expected one alert per platform resource, got %v", platformAlerts)
}
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
for _, alertID := range platformAlerts {
if _, exists := testLookupActiveAlert(t, m, alertID); !exists {
t.Fatalf("Proxmox node cleanup removed platform alert %q; remaining %v", alertID, alertKeys(m))
}
}
// The platform id alone keeps an alert whose label is not recognised.
m.mu.Lock()
labels := make(map[*Alert]interface{}, len(m.activeAlerts))
for _, alert := range m.activeAlerts {
labels[alert] = alert.Metadata["resourceType"]
alert.Metadata["resourceType"] = "Unrecognised"
}
m.mu.Unlock()
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
if got := alertKeys(m); len(got) != len(platformAlerts) {
t.Fatalf("platform alerts removed despite their platformType: %v", got)
}
m.mu.Lock()
for alert, label := range labels {
alert.Metadata["resourceType"] = label
}
m.mu.Unlock()
// A legacy-shaped alert whose metadata predates platformType is still kept
// by its display label.
m.mu.Lock()
for _, alert := range m.activeAlerts {
delete(alert.Metadata, alertPlatformTypeKey)
}
m.mu.Unlock()
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
if got := alertKeys(m); len(got) != len(platformAlerts) {
t.Fatalf("display-label platform alerts removed without platformType: %v", got)
}
}
// The cleanup still removes a Proxmox guest alert whose node left the estate.
func TestCleanupAlertsForNodesStillRemovesMissingProxmoxNodeAlerts(t *testing.T) {
m := newTestManager(t)
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
m.CheckUnifiedResource(&UnifiedResourceInput{ID: "pve-gone:node-gone:101", Type: "vm", Name: "vm-101", Node: "node-gone", Instance: "pve-gone", CPU: &UnifiedResourceMetric{Percent: 99}})
if len(alertKeys(m)) != 1 {
t.Fatalf("expected the guest CPU alert, got %v", alertKeys(m))
}
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
if got := alertKeys(m); len(got) != 0 {
t.Fatalf("guest alert on a removed node survived cleanup: %v", got)
}
}
func TestCheckZFSPoolHealth(t *testing.T) {
// t.Parallel()
poolStateAlertID := buildCanonicalStateID("local-zfs/zfs-pool:rpool", "local-zfs/zfs-pool:rpool-state")
@@ -491,19 +491,23 @@ func TestBranchCovNormalizeDiskFillByType(t *testing.T) {
}
})
t.Run("positive trigger with non-positive clear resets to default", func(t *testing.T) {
t.Run("positive trigger with non-positive clear keeps trigger and derives clear", func(t *testing.T) {
cfg := &alertconfig.AlertConfig{
DiskFillByType: map[string]alertconfig.HysteresisThreshold{
"sata": {Trigger: 88, Clear: 0},
"hdd": {Trigger: 80, Clear: -3},
"nvme": {Trigger: 0, Clear: 0},
},
}
alertconfig.NormalizeDiskFillByType(cfg)
if sata := cfg.DiskFillByType["sata"]; !htEq(sata, alertconfig.HysteresisThreshold{Trigger: 90, Clear: 85}) {
t.Fatalf("sata = %+v, want default {90 85}", sata)
if sata := cfg.DiskFillByType["sata"]; !htEq(sata, alertconfig.HysteresisThreshold{Trigger: 88, Clear: 83}) {
t.Fatalf("sata = %+v, want {88 83}", sata)
}
if hdd := cfg.DiskFillByType["hdd"]; !htEq(hdd, alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80}) {
t.Fatalf("hdd = %+v, want default {85 80}", hdd)
if hdd := cfg.DiskFillByType["hdd"]; !htEq(hdd, alertconfig.HysteresisThreshold{Trigger: 80, Clear: 75}) {
t.Fatalf("hdd = %+v, want {80 75}", hdd)
}
if nvme := cfg.DiskFillByType["nvme"]; !htEq(nvme, alertconfig.HysteresisThreshold{Trigger: 92, Clear: 87}) {
t.Fatalf("nvme = %+v, want default {92 87}", nvme)
}
})
@@ -178,14 +178,12 @@ func TestBranchCovNormalizePBSDefaults(t *testing.T) {
wantMemory: alertconfig.HysteresisThreshold{Trigger: 90, Clear: 85},
},
{
// SUSPECTED SOURCE BUG: derived clear (1-5=-4) is <=0 so the code
// falls back to the FIXED default (75 CPU / 80 Memory), producing
// Clear >> Trigger. Unlike normalizeThresholdPointer (which clamps
// to 0) and with no EnsureValidHysteresis call, this stays invalid.
name: "small trigger with zero clear falls back to fixed default yielding clear>trigger",
// Derived clear (1-5=-4) floors at 0, never the factory clear,
// which would sit above the trigger.
name: "small trigger with zero clear keeps clear below trigger",
in: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0},
wantCPU: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 75},
wantMemory: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 80},
wantCPU: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0},
wantMemory: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0},
},
}
for _, tc := range tests {
@@ -466,12 +464,10 @@ func TestBranchCovNormalizeNodeDefaults(t *testing.T) {
want: alertconfig.HysteresisThreshold{Trigger: 90, Clear: 85},
},
{
// SUSPECTED SOURCE BUG: derived clear (1-5=-4) <=0 falls back to the
// FIXED 75, giving Clear(75) > Trigger(1); NormalizeNodeDefaults has
// no EnsureValidHysteresis call so this stays invalid.
name: "small trigger zero clear falls back to fixed 75 (clear>trigger)",
// Derived clear (1-5=-4) floors at 0, never the factory 75.
name: "small trigger zero clear keeps clear below trigger",
in: &alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0},
want: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 75},
want: alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0},
},
{
name: "valid pair untouched",
@@ -588,19 +584,16 @@ func TestBranchCovNormalizeAgentDefaults(t *testing.T) {
}
})
// SUSPECTED SOURCE BUG (CPU arm): derived clear (1-5=-4) <=0 falls back to
// the FIXED 75; CPU has NO EnsureValidHysteresis call, so Clear(75) >
// Trigger(1) persists. DiskTemperature (which DOES call EnsureValidHysteresis)
// is repaired - covered by the next two subtests.
t.Run("CPU small trigger zero clear yields clear>trigger (no EnsureValidHysteresis net)", func(t *testing.T) {
// Derived clear (1-5=-4) floors at 0, never the factory 75.
t.Run("CPU small trigger zero clear keeps clear below trigger", func(t *testing.T) {
cfg := &alertconfig.AlertConfig{
AgentDefaults: alertconfig.ThresholdConfig{
CPU: &alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0},
},
}
alertconfig.NormalizeAgentDefaults(cfg)
if !ptrHtEq(cfg.AgentDefaults.CPU, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 75}) {
t.Fatalf("CPU = %+v, want {1 75} (clear>trigger anomaly)", cfg.AgentDefaults.CPU)
if !ptrHtEq(cfg.AgentDefaults.CPU, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}) {
t.Fatalf("CPU = %+v, want {1 0}", cfg.AgentDefaults.CPU)
}
})
@@ -617,17 +610,16 @@ func TestBranchCovNormalizeAgentDefaults(t *testing.T) {
}
})
t.Run("DiskTemperature small trigger: fallback to 50 then repaired to 0 by EnsureValidHysteresis", func(t *testing.T) {
t.Run("DiskTemperature small trigger zero clear keeps clear below trigger", func(t *testing.T) {
cfg := &alertconfig.AlertConfig{
AgentDefaults: alertconfig.ThresholdConfig{
DiskTemperature: &alertconfig.HysteresisThreshold{Trigger: 2, Clear: 0},
},
}
alertconfig.NormalizeAgentDefaults(cfg)
// derived clear 2-5=-3 <=0 -> fixed fallback 50; EnsureValidHysteresis
// then sees 50>=2 and sets clear=2-5=-3<0 -> 0. End state {2 0}.
// derived clear 2-5=-3 floors at 0. End state {2 0}.
if !ptrHtEq(cfg.AgentDefaults.DiskTemperature, alertconfig.HysteresisThreshold{Trigger: 2, Clear: 0}) {
t.Fatalf("DiskTemperature = %+v, want {2 0} (fallback 50 repaired to 0)", cfg.AgentDefaults.DiskTemperature)
t.Fatalf("DiskTemperature = %+v, want {2 0}", cfg.AgentDefaults.DiskTemperature)
}
})
@@ -987,7 +979,7 @@ func TestBranchCovNormalizeDiskTempByType(t *testing.T) {
}
})
t.Run("non-positive trigger or clear resets canonical key to default", func(t *testing.T) {
t.Run("non-positive trigger resets canonical key, non-positive clear derives", func(t *testing.T) {
cfg := &alertconfig.AlertConfig{
DiskTempByType: map[string]alertconfig.HysteresisThreshold{
"sata": {Trigger: 88, Clear: 0},
@@ -996,14 +988,14 @@ func TestBranchCovNormalizeDiskTempByType(t *testing.T) {
},
}
alertconfig.NormalizeDiskTempByType(cfg)
if got := cfg.DiskTempByType["sata"]; !htEq(got, wantDefaults["sata"]) {
t.Fatalf("sata = %+v, want default %+v (clear<=0)", got, wantDefaults["sata"])
if got := cfg.DiskTempByType["sata"]; !htEq(got, alertconfig.HysteresisThreshold{Trigger: 88, Clear: 83}) {
t.Fatalf("sata = %+v, want {88 83} (clear<=0 derives)", got)
}
if got := cfg.DiskTempByType["sas"]; !htEq(got, wantDefaults["sas"]) {
t.Fatalf("sas = %+v, want default %+v (trigger<=0)", got, wantDefaults["sas"])
}
if got := cfg.DiskTempByType["nvme"]; !htEq(got, wantDefaults["nvme"]) {
t.Fatalf("nvme = %+v, want default %+v (clear<=0)", got, wantDefaults["nvme"])
if got := cfg.DiskTempByType["nvme"]; !htEq(got, alertconfig.HysteresisThreshold{Trigger: 70, Clear: 65}) {
t.Fatalf("nvme = %+v, want {70 65} (clear<=0 derives)", got)
}
})
}
+32 -86
View File
@@ -161,27 +161,8 @@ func NormalizePMGDefaults(config *AlertConfig) {
// NormalizePBSDefaults ensures PBS server threshold defaults exist.
// Trigger=0 is allowed and means "disable alerting for this metric".
func NormalizePBSDefaults(config *AlertConfig) {
if config.PBSDefaults.CPU == nil || config.PBSDefaults.CPU.Trigger < 0 {
config.PBSDefaults.CPU = &HysteresisThreshold{Trigger: 80, Clear: 75}
} else if config.PBSDefaults.CPU.Trigger == 0 {
config.PBSDefaults.CPU.Clear = 0
} else if config.PBSDefaults.CPU.Clear <= 0 {
config.PBSDefaults.CPU.Clear = config.PBSDefaults.CPU.Trigger - 5
if config.PBSDefaults.CPU.Clear <= 0 {
config.PBSDefaults.CPU.Clear = 75
}
}
if config.PBSDefaults.Memory == nil || config.PBSDefaults.Memory.Trigger < 0 {
config.PBSDefaults.Memory = &HysteresisThreshold{Trigger: 85, Clear: 80}
} else if config.PBSDefaults.Memory.Trigger == 0 {
config.PBSDefaults.Memory.Clear = 0
} else if config.PBSDefaults.Memory.Clear <= 0 {
config.PBSDefaults.Memory.Clear = config.PBSDefaults.Memory.Trigger - 5
if config.PBSDefaults.Memory.Clear <= 0 {
config.PBSDefaults.Memory.Clear = 80
}
}
config.PBSDefaults.CPU = normalizeThresholdPointer(config.PBSDefaults.CPU, 80, 75, "pbs.cpu")
config.PBSDefaults.Memory = normalizeThresholdPointer(config.PBSDefaults.Memory, 85, 80, "pbs.memory")
}
func NormalizeSnapshotDefaults(config *AlertConfig) {
@@ -290,61 +271,14 @@ func NormalizeBackupDefaults(config *AlertConfig) {
}
func NormalizeNodeDefaults(config *AlertConfig) {
if config.NodeDefaults.Temperature == nil || config.NodeDefaults.Temperature.Trigger < 0 {
config.NodeDefaults.Temperature = &HysteresisThreshold{Trigger: 80, Clear: 75}
} else if config.NodeDefaults.Temperature.Trigger == 0 {
config.NodeDefaults.Temperature.Clear = 0
} else if config.NodeDefaults.Temperature.Clear <= 0 {
config.NodeDefaults.Temperature.Clear = config.NodeDefaults.Temperature.Trigger - 5
if config.NodeDefaults.Temperature.Clear <= 0 {
config.NodeDefaults.Temperature.Clear = 75
}
}
config.NodeDefaults.Temperature = normalizeThresholdPointer(config.NodeDefaults.Temperature, 80, 75, "node.temperature")
}
func NormalizeAgentDefaults(config *AlertConfig) {
if config.AgentDefaults.CPU == nil || config.AgentDefaults.CPU.Trigger < 0 {
config.AgentDefaults.CPU = &HysteresisThreshold{Trigger: 80, Clear: 75}
} else if config.AgentDefaults.CPU.Trigger == 0 {
config.AgentDefaults.CPU.Clear = 0
} else if config.AgentDefaults.CPU.Clear <= 0 {
config.AgentDefaults.CPU.Clear = config.AgentDefaults.CPU.Trigger - 5
if config.AgentDefaults.CPU.Clear <= 0 {
config.AgentDefaults.CPU.Clear = 75
}
}
if config.AgentDefaults.Memory == nil || config.AgentDefaults.Memory.Trigger < 0 {
config.AgentDefaults.Memory = &HysteresisThreshold{Trigger: 85, Clear: 80}
} else if config.AgentDefaults.Memory.Trigger == 0 {
config.AgentDefaults.Memory.Clear = 0
} else if config.AgentDefaults.Memory.Clear <= 0 {
config.AgentDefaults.Memory.Clear = config.AgentDefaults.Memory.Trigger - 5
if config.AgentDefaults.Memory.Clear <= 0 {
config.AgentDefaults.Memory.Clear = 80
}
}
if config.AgentDefaults.Disk == nil || config.AgentDefaults.Disk.Trigger < 0 {
config.AgentDefaults.Disk = &HysteresisThreshold{Trigger: 90, Clear: 85}
} else if config.AgentDefaults.Disk.Trigger == 0 {
config.AgentDefaults.Disk.Clear = 0
} else if config.AgentDefaults.Disk.Clear <= 0 {
config.AgentDefaults.Disk.Clear = config.AgentDefaults.Disk.Trigger - 5
if config.AgentDefaults.Disk.Clear <= 0 {
config.AgentDefaults.Disk.Clear = 85
}
}
if config.AgentDefaults.DiskTemperature == nil || config.AgentDefaults.DiskTemperature.Trigger < 0 {
config.AgentDefaults.DiskTemperature = &HysteresisThreshold{Trigger: 55, Clear: 50}
} else if config.AgentDefaults.DiskTemperature.Trigger == 0 {
config.AgentDefaults.DiskTemperature.Clear = 0
} else if config.AgentDefaults.DiskTemperature.Clear <= 0 {
config.AgentDefaults.DiskTemperature.Clear = config.AgentDefaults.DiskTemperature.Trigger - 5
if config.AgentDefaults.DiskTemperature.Clear <= 0 {
config.AgentDefaults.DiskTemperature.Clear = 50
}
}
EnsureValidHysteresis(config.AgentDefaults.DiskTemperature, "agent.diskTemperature")
config.AgentDefaults.CPU = normalizeThresholdPointer(config.AgentDefaults.CPU, 80, 75, "agent.cpu")
config.AgentDefaults.Memory = normalizeThresholdPointer(config.AgentDefaults.Memory, 85, 80, "agent.memory")
config.AgentDefaults.Disk = normalizeThresholdPointer(config.AgentDefaults.Disk, 90, 85, "agent.disk")
config.AgentDefaults.DiskTemperature = normalizeThresholdPointer(config.AgentDefaults.DiskTemperature, 55, 50, "agent.diskTemperature")
normalizeSMARTDefaults(&config.AgentDefaults)
NormalizeDiskFillByType(config)
@@ -396,6 +330,12 @@ func smartIntPtr(value int) *int { return &value }
func smartInt64Ptr(value int64) *int64 { return &value }
// normalizeThresholdPointer is the canonical hysteresis normalizer. A missing
// or negative threshold takes the factory default, a zero trigger means off,
// and a positive trigger always keeps a clear below it: a missing clear sits
// 5 points under the trigger, floored at 0 for triggers of 5 or less, never
// the factory clear (which would sit above a low trigger), and a clear at or
// above the trigger is repaired.
func normalizeThresholdPointer(
current *HysteresisThreshold,
defaultTrigger float64,
@@ -420,6 +360,12 @@ func normalizeThresholdPointer(
return &normalized
}
// NormalizeHysteresisThreshold exposes normalizeThresholdPointer for other
// packages (e.g., config persistence).
func NormalizeHysteresisThreshold(current *HysteresisThreshold, defaultTrigger, defaultClear float64, metricName string) *HysteresisThreshold {
return normalizeThresholdPointer(current, defaultTrigger, defaultClear, metricName)
}
func NormalizeKubernetesDefaults(config *AlertConfig) {
config.KubernetesDefaults.CPU = normalizeThresholdPointer(config.KubernetesDefaults.CPU, 80, 75, "kubernetes.cpu")
config.KubernetesDefaults.Memory = normalizeThresholdPointer(config.KubernetesDefaults.Memory, 85, 80, "kubernetes.memory")
@@ -468,8 +414,8 @@ func diskFillByTypeDefaults() map[string]HysteresisThreshold {
// NormalizeDiskFillByType ensures AlertConfig.DiskFillByType is seeded with
// lowercase nvme/sata/hdd defaults when nil, lowercases any existing keys,
// and resets non-positive trigger or clear values to the default for that
// key. Operator-customized positive values are preserved.
// and resets a non-positive trigger to the default for that key. A positive
// trigger is preserved and its clear follows normalizeThresholdPointer.
func NormalizeDiskFillByType(config *AlertConfig) {
defaults := diskFillByTypeDefaults()
if config.DiskFillByType == nil {
@@ -496,16 +442,14 @@ func NormalizeDiskFillByType(config *AlertConfig) {
}
}
// Ensure all canonical keys are present and have positive trigger/clear values.
// Ensure all canonical keys are present with a positive trigger.
for key, defaultVal := range defaults {
current, ok := config.DiskFillByType[key]
if !ok {
if !ok || current.Trigger <= 0 {
config.DiskFillByType[key] = defaultVal
continue
}
if current.Trigger <= 0 || current.Clear <= 0 {
config.DiskFillByType[key] = defaultVal
}
config.DiskFillByType[key] = *normalizeThresholdPointer(&current, defaultVal.Trigger, defaultVal.Clear, "diskFillByType."+key)
}
}
@@ -521,8 +465,8 @@ func diskTempByTypeDefaults() map[string]HysteresisThreshold {
// NormalizeDiskTempByType ensures AlertConfig.DiskTempByType is seeded with
// lowercase nvme/sas/sata defaults when nil, lowercases any existing keys,
// and resets non-positive trigger or clear values to the default for that key.
// Operator-customized positive values are preserved.
// and resets a non-positive trigger to the default for that key. A positive
// trigger is preserved and its clear follows normalizeThresholdPointer.
func NormalizeDiskTempByType(config *AlertConfig) {
defaults := diskTempByTypeDefaults()
if config.DiskTempByType == nil {
@@ -550,13 +494,11 @@ func NormalizeDiskTempByType(config *AlertConfig) {
for key, defaultVal := range defaults {
current, ok := config.DiskTempByType[key]
if !ok {
if !ok || current.Trigger <= 0 {
config.DiskTempByType[key] = defaultVal
continue
}
if current.Trigger <= 0 || current.Clear <= 0 {
config.DiskTempByType[key] = defaultVal
}
config.DiskTempByType[key] = *normalizeThresholdPointer(&current, defaultVal.Trigger, defaultVal.Clear, "diskTempByType."+key)
}
}
@@ -644,6 +586,10 @@ func ValidateHysteresisThresholds(config *AlertConfig) {
EnsureValidHysteresis(config.VMwareDefaults.Memory, "vmware.memory")
EnsureValidHysteresis(config.VMwareDefaults.Disk, "vmware.disk")
EnsureValidHysteresis(config.VMwareDefaults.Usage, "vmware.usage")
EnsureValidHysteresis(config.AgentDefaults.CPU, "agent.cpu")
EnsureValidHysteresis(config.AgentDefaults.Memory, "agent.memory")
EnsureValidHysteresis(config.AgentDefaults.Disk, "agent.disk")
EnsureValidHysteresis(config.AgentDefaults.DiskTemperature, "agent.diskTemperature")
}
func ValidateQuietHoursTimezone(config *AlertConfig) {
@@ -0,0 +1,212 @@
package config_test
import (
"testing"
alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config"
)
// normalizeLikeUpdateConfig runs the threshold normalizers in the order
// alerts.Manager.UpdateConfig runs them.
func normalizeLikeUpdateConfig(cfg *alertconfig.AlertConfig) {
alertconfig.NormalizeStorageDefaults(cfg)
alertconfig.NormalizeDockerDefaults(cfg)
alertconfig.NormalizePBSDefaults(cfg)
alertconfig.NormalizeNodeDefaults(cfg)
alertconfig.NormalizeAgentDefaults(cfg)
alertconfig.NormalizeKubernetesDefaults(cfg)
alertconfig.NormalizeTrueNASDefaults(cfg)
alertconfig.NormalizeVMwareDefaults(cfg)
alertconfig.ValidateHysteresisThresholds(cfg)
}
type defaultThresholdSlot struct {
name string
set func(*alertconfig.AlertConfig, alertconfig.HysteresisThreshold)
get func(*alertconfig.AlertConfig) alertconfig.HysteresisThreshold
}
func pointerSlot(name string, field func(*alertconfig.AlertConfig) **alertconfig.HysteresisThreshold) defaultThresholdSlot {
return defaultThresholdSlot{
name: name,
set: func(cfg *alertconfig.AlertConfig, threshold alertconfig.HysteresisThreshold) {
*field(cfg) = &threshold
},
get: func(cfg *alertconfig.AlertConfig) alertconfig.HysteresisThreshold {
if got := *field(cfg); got != nil {
return *got
}
return alertconfig.HysteresisThreshold{Trigger: -1, Clear: -1}
},
}
}
func valueSlot(name string, field func(*alertconfig.AlertConfig) *alertconfig.HysteresisThreshold) defaultThresholdSlot {
return defaultThresholdSlot{
name: name,
set: func(cfg *alertconfig.AlertConfig, threshold alertconfig.HysteresisThreshold) {
*field(cfg) = threshold
},
get: func(cfg *alertconfig.AlertConfig) alertconfig.HysteresisThreshold {
return *field(cfg)
},
}
}
// percentDefaultThresholdSlots lists every global default the thresholds page
// edits as a hysteresis pair that a user can set to a single-digit value.
func percentDefaultThresholdSlots() []defaultThresholdSlot {
type cfg = alertconfig.AlertConfig
type th = alertconfig.HysteresisThreshold
return []defaultThresholdSlot{
pointerSlot("guest.cpu", func(c *cfg) **th { return &c.GuestDefaults.CPU }),
pointerSlot("guest.memory", func(c *cfg) **th { return &c.GuestDefaults.Memory }),
pointerSlot("guest.disk", func(c *cfg) **th { return &c.GuestDefaults.Disk }),
pointerSlot("node.cpu", func(c *cfg) **th { return &c.NodeDefaults.CPU }),
pointerSlot("node.memory", func(c *cfg) **th { return &c.NodeDefaults.Memory }),
pointerSlot("node.temperature", func(c *cfg) **th { return &c.NodeDefaults.Temperature }),
pointerSlot("pbs.cpu", func(c *cfg) **th { return &c.PBSDefaults.CPU }),
pointerSlot("pbs.memory", func(c *cfg) **th { return &c.PBSDefaults.Memory }),
pointerSlot("agent.cpu", func(c *cfg) **th { return &c.AgentDefaults.CPU }),
pointerSlot("agent.memory", func(c *cfg) **th { return &c.AgentDefaults.Memory }),
pointerSlot("agent.disk", func(c *cfg) **th { return &c.AgentDefaults.Disk }),
pointerSlot("agent.diskTemperature", func(c *cfg) **th { return &c.AgentDefaults.DiskTemperature }),
pointerSlot("kubernetes.cpu", func(c *cfg) **th { return &c.KubernetesDefaults.CPU }),
pointerSlot("kubernetes.memory", func(c *cfg) **th { return &c.KubernetesDefaults.Memory }),
pointerSlot("kubernetes.disk", func(c *cfg) **th { return &c.KubernetesDefaults.Disk }),
pointerSlot("truenas.cpu", func(c *cfg) **th { return &c.TrueNASDefaults.CPU }),
pointerSlot("truenas.memory", func(c *cfg) **th { return &c.TrueNASDefaults.Memory }),
pointerSlot("truenas.usage", func(c *cfg) **th { return &c.TrueNASDefaults.Usage }),
pointerSlot("truenas.temperature", func(c *cfg) **th { return &c.TrueNASDefaults.Temperature }),
pointerSlot("vmware.cpu", func(c *cfg) **th { return &c.VMwareDefaults.CPU }),
pointerSlot("vmware.memory", func(c *cfg) **th { return &c.VMwareDefaults.Memory }),
pointerSlot("vmware.usage", func(c *cfg) **th { return &c.VMwareDefaults.Usage }),
valueSlot("docker.cpu", func(c *cfg) *th { return &c.DockerDefaults.CPU }),
valueSlot("docker.memory", func(c *cfg) *th { return &c.DockerDefaults.Memory }),
valueSlot("docker.disk", func(c *cfg) *th { return &c.DockerDefaults.Disk }),
valueSlot("storage", func(c *cfg) *th { return &c.StorageDefault }),
}
}
// The thresholds page sends a positive trigger with clear max(0, trigger-5),
// so a trigger at or below the 5-point margin arrives with clear 0. Every
// default family must keep that clear below the trigger. PBS, node
// temperature and agent defaults used to fall back to the factory clear,
// storing {trigger: 1, clear: 75} for a 1% Machines CPU default.
func TestLowTriggerDefaultsKeepClearBelowTrigger(t *testing.T) {
cases := []struct {
in alertconfig.HysteresisThreshold
want alertconfig.HysteresisThreshold
}{
{alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}},
{alertconfig.HysteresisThreshold{Trigger: 2.5, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 2.5, Clear: 0}},
{alertconfig.HysteresisThreshold{Trigger: 5, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 5, Clear: 0}},
// A clear at or above the trigger, such as one stored before this
// fix, is repaired the same way.
{alertconfig.HysteresisThreshold{Trigger: 1, Clear: 75}, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}},
{alertconfig.HysteresisThreshold{Trigger: 50, Clear: 60}, alertconfig.HysteresisThreshold{Trigger: 50, Clear: 45}},
// Off stays off.
{alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}},
}
for _, slot := range percentDefaultThresholdSlots() {
for _, tc := range cases {
cfg := &alertconfig.AlertConfig{}
slot.set(cfg, tc.in)
normalizeLikeUpdateConfig(cfg)
if got := slot.get(cfg); got != tc.want {
t.Errorf("%s %+v normalized to %+v, want %+v", slot.name, tc.in, got, tc.want)
}
}
}
}
// A clear at or above the trigger that reaches ValidateHysteresisThresholds
// directly is repaired for agent defaults too, as for every other family.
func TestValidateHysteresisThresholdsRepairsAgentDefaults(t *testing.T) {
cfg := &alertconfig.AlertConfig{
AgentDefaults: alertconfig.ThresholdConfig{
CPU: &alertconfig.HysteresisThreshold{Trigger: 1, Clear: 75},
Memory: &alertconfig.HysteresisThreshold{Trigger: 50, Clear: 50},
Disk: &alertconfig.HysteresisThreshold{Trigger: 90, Clear: 95},
DiskTemperature: &alertconfig.HysteresisThreshold{Trigger: 55, Clear: 60},
},
}
alertconfig.ValidateHysteresisThresholds(cfg)
for name, tc := range map[string]struct {
got *alertconfig.HysteresisThreshold
want alertconfig.HysteresisThreshold
}{
"cpu": {cfg.AgentDefaults.CPU, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}},
"memory": {cfg.AgentDefaults.Memory, alertconfig.HysteresisThreshold{Trigger: 50, Clear: 45}},
"disk": {cfg.AgentDefaults.Disk, alertconfig.HysteresisThreshold{Trigger: 90, Clear: 85}},
"diskTemperature": {cfg.AgentDefaults.DiskTemperature, alertconfig.HysteresisThreshold{Trigger: 55, Clear: 50}},
} {
if !ptrHtEq(tc.got, tc.want) {
t.Errorf("agent %s = %+v, want %+v", name, tc.got, tc.want)
}
}
}
// Per-type disk fill and disk temperature entries keep a positive trigger and
// follow the same clear rule. They used to reset to the factory pair whenever
// the clear was not positive, losing a trigger of 1-5 (the thresholds page
// sends those with clear 0) and an API entry such as {88, 0}. A non-positive
// trigger still resets to the default, because a per-type entry cannot be
// switched off on its own.
func TestPerTypeDiskEntriesKeepTriggerAndClearBelowIt(t *testing.T) {
maps := map[string]struct {
set func(*alertconfig.AlertConfig, string, alertconfig.HysteresisThreshold)
get func(*alertconfig.AlertConfig, string) alertconfig.HysteresisThreshold
defaults map[string]alertconfig.HysteresisThreshold
}{
"diskFillByType": {
set: func(cfg *alertconfig.AlertConfig, key string, th alertconfig.HysteresisThreshold) {
cfg.DiskFillByType = map[string]alertconfig.HysteresisThreshold{key: th}
},
get: func(cfg *alertconfig.AlertConfig, key string) alertconfig.HysteresisThreshold {
return cfg.DiskFillByType[key]
},
defaults: map[string]alertconfig.HysteresisThreshold{
"nvme": {Trigger: 92, Clear: 87},
"sata": {Trigger: 90, Clear: 85},
"hdd": {Trigger: 85, Clear: 80},
},
},
"diskTempByType": {
set: func(cfg *alertconfig.AlertConfig, key string, th alertconfig.HysteresisThreshold) {
cfg.DiskTempByType = map[string]alertconfig.HysteresisThreshold{key: th}
},
get: func(cfg *alertconfig.AlertConfig, key string) alertconfig.HysteresisThreshold {
return cfg.DiskTempByType[key]
},
defaults: map[string]alertconfig.HysteresisThreshold{
"nvme": {Trigger: 70, Clear: 65},
"sas": {Trigger: 65, Clear: 60},
"sata": {Trigger: 55, Clear: 50},
},
},
}
for mapName, m := range maps {
for key, factory := range m.defaults {
for _, tc := range []struct {
in alertconfig.HysteresisThreshold
want alertconfig.HysteresisThreshold
}{
{alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}},
{alertconfig.HysteresisThreshold{Trigger: 5, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 5, Clear: 0}},
{alertconfig.HysteresisThreshold{Trigger: 88, Clear: 0}, alertconfig.HysteresisThreshold{Trigger: 88, Clear: 83}},
{alertconfig.HysteresisThreshold{Trigger: 1, Clear: 75}, alertconfig.HysteresisThreshold{Trigger: 1, Clear: 0}},
{alertconfig.HysteresisThreshold{Trigger: 60, Clear: 70}, alertconfig.HysteresisThreshold{Trigger: 60, Clear: 55}},
{alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}, factory},
{alertconfig.HysteresisThreshold{Trigger: -1, Clear: 0}, factory},
} {
cfg := &alertconfig.AlertConfig{}
m.set(cfg, key, tc.in)
normalizeLikeUpdateConfig(cfg)
if got := m.get(cfg, key); got != tc.want {
t.Errorf("%s[%s] %+v normalized to %+v, want %+v", mapName, key, tc.in, got, tc.want)
}
}
}
}
}
+4
View File
@@ -86,6 +86,10 @@ func NormalizeMetricEvaluationWindows(input map[string]map[string]int) map[strin
return alertconfig.NormalizeMetricEvaluationWindows(input)
}
func NormalizeHysteresisThreshold(threshold *HysteresisThreshold, defaultTrigger, defaultClear float64, metricName string) *HysteresisThreshold {
return alertconfig.NormalizeHysteresisThreshold(threshold, defaultTrigger, defaultClear, metricName)
}
func NormalizeDockerIgnoredPrefixes(prefixes []string) []string {
return alertconfig.NormalizeDockerIgnoredPrefixes(prefixes)
}
+81 -1
View File
@@ -1,6 +1,11 @@
package alerts
import "testing"
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestUpdateConfigNormalizesBackupIndicatorHours(t *testing.T) {
t.Run("non-positive values default", func(t *testing.T) {
@@ -83,6 +88,81 @@ func TestUpdateConfigNormalizesFlappingSettings(t *testing.T) {
}
}
// The thresholds page sends a positive trigger with clear max(0, trigger-5),
// so a 1-5% default arrives with clear 0. Agent defaults used to fall back to
// the factory clear, storing {trigger: 1, clear: 75}; the evaluator ignored
// that clear and cleared at the trigger, but firing alerts reported 75 as
// their clear level.
func TestUpdateConfigKeepsLowTriggerClearBelowTrigger(t *testing.T) {
m := newTestManager(t)
m.UpdateConfig(AlertConfig{
Enabled: true,
AgentDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 1, Clear: 0},
Memory: &HysteresisThreshold{Trigger: 3, Clear: 0},
Disk: &HysteresisThreshold{Trigger: 5, Clear: 0},
},
NodeDefaults: ThresholdConfig{
Temperature: &HysteresisThreshold{Trigger: 2, Clear: 0},
},
PBSDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 4, Clear: 0},
Memory: &HysteresisThreshold{Trigger: 5, Clear: 0},
},
})
cfg := m.GetConfig()
for name, tc := range map[string]struct {
got *HysteresisThreshold
want HysteresisThreshold
}{
"agent cpu": {cfg.AgentDefaults.CPU, HysteresisThreshold{Trigger: 1, Clear: 0}},
"agent memory": {cfg.AgentDefaults.Memory, HysteresisThreshold{Trigger: 3, Clear: 0}},
"agent disk": {cfg.AgentDefaults.Disk, HysteresisThreshold{Trigger: 5, Clear: 0}},
"node temperature": {cfg.NodeDefaults.Temperature, HysteresisThreshold{Trigger: 2, Clear: 0}},
"pbs cpu": {cfg.PBSDefaults.CPU, HysteresisThreshold{Trigger: 4, Clear: 0}},
"pbs memory": {cfg.PBSDefaults.Memory, HysteresisThreshold{Trigger: 5, Clear: 0}},
} {
if tc.got == nil || *tc.got != tc.want {
t.Errorf("%s = %+v, want %+v", name, tc.got, tc.want)
}
}
m.mu.Lock()
m.config.TimeThresholds = map[string]int{}
m.config.ActivationState = ActivationActive
m.mu.Unlock()
host := models.Host{
ID: "host-low-trigger",
DisplayName: "Low Trigger Host",
Hostname: "low-trigger.example",
Memory: models.Memory{Usage: 68.9, Total: 16384, Used: 11288, Free: 5096},
Status: "online",
IntervalSeconds: 30,
LastSeen: time.Now(),
}
m.CheckHost(host)
memAlert, exists := testLookupActiveAlert(t, m, canonicalMetricStateID(hostResourceID(host.ID), "memory"))
if !exists {
t.Fatalf("expected memory alert at a 3%% trigger")
}
// No band below a 3% trigger: the alert clears at the trigger itself.
if got := memAlert.Metadata["clearThreshold"]; got != 3.0 {
t.Fatalf("memory alert clearThreshold = %v, want 3", got)
}
// A config saved before the fix is repaired on its next load.
m.UpdateConfig(AlertConfig{
Enabled: true,
AgentDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 1, Clear: 75},
},
})
if got := m.GetConfig().AgentDefaults.CPU; got == nil || *got != (HysteresisThreshold{Trigger: 1, Clear: 0}) {
t.Fatalf("stored agent cpu {1 75} normalized to %+v, want {1 0}", got)
}
}
func TestUpdateConfigNormalizesExactEscalationRoutingAndRepeatPolicy(t *testing.T) {
m := newTestManager(t)
cfg := m.GetConfig()
@@ -246,7 +246,7 @@ func TestNormalizeDiskFillByType(t *testing.T) {
}
})
t.Run("negative trigger resets to default", func(t *testing.T) {
t.Run("negative trigger resets to default, negative clear derives", func(t *testing.T) {
cfg := &AlertConfig{
DiskFillByType: map[string]HysteresisThreshold{
"nvme": {Trigger: -1, Clear: 10},
@@ -259,9 +259,10 @@ func TestNormalizeDiskFillByType(t *testing.T) {
if nvme.Trigger != 92 || nvme.Clear != 87 {
t.Fatalf("nvme = %+v, want default reset {Trigger:92 Clear:87}", nvme)
}
// A positive trigger is kept; only its clear is derived.
sata := cfg.DiskFillByType["sata"]
if sata.Trigger != 90 || sata.Clear != 85 {
t.Fatalf("sata = %+v, want default reset {Trigger:90 Clear:85}", sata)
if sata.Trigger != 88 || sata.Clear != 83 {
t.Fatalf("sata = %+v, want {Trigger:88 Clear:83}", sata)
}
})
@@ -356,7 +357,7 @@ func TestNormalizeDiskTempByType(t *testing.T) {
}
})
t.Run("negative trigger resets to default", func(t *testing.T) {
t.Run("negative trigger resets to default, negative clear derives", func(t *testing.T) {
cfg := &AlertConfig{
DiskTempByType: map[string]HysteresisThreshold{
"nvme": {Trigger: -1, Clear: 10},
@@ -369,9 +370,10 @@ func TestNormalizeDiskTempByType(t *testing.T) {
if nvme.Trigger != 70 || nvme.Clear != 65 {
t.Fatalf("nvme = %+v, want default reset {Trigger:70 Clear:65}", nvme)
}
// A positive trigger is kept; only its clear is derived.
sas := cfg.DiskTempByType["sas"]
if sas.Trigger != 65 || sas.Clear != 60 {
t.Fatalf("sas = %+v, want default reset {Trigger:65 Clear:60}", sas)
if sas.Trigger != 64 || sas.Clear != 59 {
t.Fatalf("sas = %+v, want {Trigger:64 Clear:59}", sas)
}
})
+5 -39
View File
@@ -817,40 +817,6 @@ func (c *ConfigPersistence) persistAPITokensLocked(tokens []APITokenRecord) erro
return nil
}
// normalizeHysteresisThreshold ensures a hysteresis threshold pointer has valid
// trigger/clear values. If the pointer is nil or trigger is negative, it is set
// to the given defaults. Trigger==0 means "disabled" (clear forced to 0).
// Otherwise a non-positive clear is derived from trigger-5 with defaultClear as floor.
func normalizeHysteresisThreshold(t **alerts.HysteresisThreshold, defaultTrigger, defaultClear float64) {
if *t == nil || (*t).Trigger < 0 {
*t = &alerts.HysteresisThreshold{Trigger: defaultTrigger, Clear: defaultClear}
} else if (*t).Trigger == 0 {
(*t).Clear = 0
} else if (*t).Clear <= 0 {
(*t).Clear = (*t).Trigger - 5
if (*t).Clear <= 0 {
(*t).Clear = defaultClear
}
}
}
// normalizeStorageDefault normalizes a non-pointer HysteresisThreshold used for
// storage defaults. Same logic as normalizeHysteresisThreshold but operates on
// a value (not pointer) and uses 0 as the clear floor.
func normalizeStorageDefault(t *alerts.HysteresisThreshold) {
if t.Trigger < 0 {
t.Trigger = 85
t.Clear = 80
} else if t.Trigger == 0 {
t.Clear = 0
} else if t.Clear <= 0 {
t.Clear = t.Trigger - 5
if t.Clear < 0 {
t.Clear = 0
}
}
}
// normalizeAlertDefaults applies shared normalization logic to an AlertConfig.
// Called by both SaveAlertConfig and LoadAlertConfig to avoid duplicating the
// same validation and defaulting code.
@@ -858,7 +824,7 @@ func normalizeAlertDefaults(config *alerts.AlertConfig) {
alerts.NormalizeAlertConfigAliases(config)
// Storage threshold
normalizeStorageDefault(&config.StorageDefault)
config.StorageDefault = *alerts.NormalizeHysteresisThreshold(&config.StorageDefault, 85, 80, "storage")
if config.MinimumDelta <= 0 {
config.MinimumDelta = 2.0
@@ -871,9 +837,9 @@ func normalizeAlertDefaults(config *alerts.AlertConfig) {
}
// Agent defaults
normalizeHysteresisThreshold(&config.AgentDefaults.CPU, 80, 75)
normalizeHysteresisThreshold(&config.AgentDefaults.Memory, 85, 80)
normalizeHysteresisThreshold(&config.AgentDefaults.Disk, 90, 85)
config.AgentDefaults.CPU = alerts.NormalizeHysteresisThreshold(config.AgentDefaults.CPU, 80, 75, "agent.cpu")
config.AgentDefaults.Memory = alerts.NormalizeHysteresisThreshold(config.AgentDefaults.Memory, 85, 80, "agent.memory")
config.AgentDefaults.Disk = alerts.NormalizeHysteresisThreshold(config.AgentDefaults.Disk, 90, 85, "agent.disk")
// Time thresholds
config.MetricTimeThresholds = alerts.NormalizeMetricTimeThresholds(config.MetricTimeThresholds)
@@ -1135,7 +1101,7 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
}
// Load-specific: NodeDefaults.Temperature normalization
normalizeHysteresisThreshold(&config.NodeDefaults.Temperature, 80, 75)
config.NodeDefaults.Temperature = alerts.NormalizeHysteresisThreshold(config.NodeDefaults.Temperature, 80, 75, "node.temperature")
// Shared normalization (storage, host defaults, time thresholds, snapshots, backups, etc.)
normalizeAlertDefaults(&config)
+117
View File
@@ -183,6 +183,123 @@ func TestSaveAlertConfig_NormalizesAgentDefaultsClear(t *testing.T) {
}
}
// A positive trigger at or below the 5-point hysteresis margin arrives from
// the thresholds page with clear 0 (max(0, trigger-5)). It must keep a clear
// below its trigger, never fall back to the factory clear: a 1% Machines CPU
// default used to store {trigger: 1, clear: 75}.
func TestSaveAlertConfig_LowTriggerKeepsClearBelowTrigger(t *testing.T) {
tempDir := t.TempDir()
cp := config.NewConfigPersistence(tempDir)
if err := cp.EnsureConfigDir(); err != nil {
t.Fatalf("EnsureConfigDir: %v", err)
}
cfg := alerts.AlertConfig{
Enabled: true,
StorageDefault: alerts.HysteresisThreshold{Trigger: 3, Clear: 0},
NodeDefaults: alerts.ThresholdConfig{
Temperature: &alerts.HysteresisThreshold{Trigger: 4, Clear: 0},
},
AgentDefaults: alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 1, Clear: 0},
Memory: &alerts.HysteresisThreshold{Trigger: 3, Clear: 0},
Disk: &alerts.HysteresisThreshold{Trigger: 5, Clear: 0},
},
}
if err := cp.SaveAlertConfig(cfg); err != nil {
t.Fatalf("SaveAlertConfig: %v", err)
}
// The file itself must hold the pairs, not only the loaded config, so a
// load-time repair cannot mask a bad save.
data, err := os.ReadFile(filepath.Join(tempDir, "alerts.json"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var saved alerts.AlertConfig
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("Unmarshal saved alerts.json: %v", err)
}
loaded, err := cp.LoadAlertConfig()
if err != nil {
t.Fatalf("LoadAlertConfig: %v", err)
}
for _, view := range []struct {
name string
cfg *alerts.AlertConfig
}{{"saved", &saved}, {"loaded", loaded}} {
for name, tc := range map[string]struct {
got *alerts.HysteresisThreshold
want alerts.HysteresisThreshold
}{
"agent cpu": {view.cfg.AgentDefaults.CPU, alerts.HysteresisThreshold{Trigger: 1, Clear: 0}},
"agent memory": {view.cfg.AgentDefaults.Memory, alerts.HysteresisThreshold{Trigger: 3, Clear: 0}},
"agent disk": {view.cfg.AgentDefaults.Disk, alerts.HysteresisThreshold{Trigger: 5, Clear: 0}},
"storage": {&view.cfg.StorageDefault, alerts.HysteresisThreshold{Trigger: 3, Clear: 0}},
} {
if tc.got == nil || *tc.got != tc.want {
t.Errorf("%s %s = %+v, want %+v", view.name, name, tc.got, tc.want)
}
}
}
if got := loaded.NodeDefaults.Temperature; got == nil || *got != (alerts.HysteresisThreshold{Trigger: 4, Clear: 0}) {
t.Errorf("loaded node temperature = %+v, want {4 0}", got)
}
}
// An alerts.json written before the fix holds a clear above its trigger; it
// loads repaired rather than serving the factory clear back to the UI.
func TestLoadAlertConfig_RepairsStoredClearAboveTrigger(t *testing.T) {
tempDir := t.TempDir()
cp := config.NewConfigPersistence(tempDir)
if err := cp.EnsureConfigDir(); err != nil {
t.Fatalf("EnsureConfigDir: %v", err)
}
raw := alerts.AlertConfig{
Enabled: true,
StorageDefault: alerts.HysteresisThreshold{Trigger: 1, Clear: 80},
NodeDefaults: alerts.ThresholdConfig{
Temperature: &alerts.HysteresisThreshold{Trigger: 2, Clear: 75},
},
AgentDefaults: alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 1, Clear: 75},
Memory: &alerts.HysteresisThreshold{Trigger: 50, Clear: 80},
Disk: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85},
},
}
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "alerts.json"), data, 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
loaded, err := cp.LoadAlertConfig()
if err != nil {
t.Fatalf("LoadAlertConfig: %v", err)
}
for name, tc := range map[string]struct {
got *alerts.HysteresisThreshold
want alerts.HysteresisThreshold
}{
"agent cpu": {loaded.AgentDefaults.CPU, alerts.HysteresisThreshold{Trigger: 1, Clear: 0}},
"agent memory": {loaded.AgentDefaults.Memory, alerts.HysteresisThreshold{Trigger: 50, Clear: 45}},
"agent disk": {loaded.AgentDefaults.Disk, alerts.HysteresisThreshold{Trigger: 90, Clear: 85}},
"node temperature": {loaded.NodeDefaults.Temperature, alerts.HysteresisThreshold{Trigger: 2, Clear: 0}},
"storage": {&loaded.StorageDefault, alerts.HysteresisThreshold{Trigger: 1, Clear: 0}},
} {
if tc.got == nil || *tc.got != tc.want {
t.Errorf("%s = %+v, want %+v", name, tc.got, tc.want)
}
}
}
// TestSaveAlertConfig_AgentDefaultsZeroDisablesAlerting verifies that setting
// Host Agent thresholds to 0 is preserved (fixes GitHub issue #864).
// Setting a threshold to 0 should disable alerting for that metric.