mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge remote-tracking branch 'origin/main' into fix/alert-config-keep-unsent-fields
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
-1
@@ -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,
|
||||
|
||||
-68
@@ -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 = {
|
||||
|
||||
-118
@@ -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({
|
||||
|
||||
-106
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(¤t, 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(¤t, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user