Commit Graph

6133 Commits

Author SHA1 Message Date
rcourtman b7049ff778 Raise Proxmox disk temperature alerts under the per-type policy (#2680)
**Stacked on #2675** (TrueNAS disk temperature alerts by disk type).
Merge that first; this branch contains its commit.

## Problem

A Proxmox physical disk that only the node's own sensors report never
raised a temperature alert:

- `CheckDiskHealth` judges health and wearout only;
- unified evaluation types TrueNAS physical disks, not PVE ones.

So Physical Disks could read a disk **Running Hot** under the per-type
thresholds while alerts stayed silent. Whether a disk alerted depended
on whether a Pulse agent ran on the node: agent-covered disks alert
through `CheckHost` at the same thresholds. #1347 shows users look for
disk temperature thresholds in the Proxmox tabs.

## Change

- **`CheckProxmoxDiskTemperature`**
(`internal/alerts/proxmox_disk_temperature.go`) raises a
`diskTemperature` metric alert under the disk's existing PVE alert
reference. It is judged by the disk temperature policy: the disk type's
`DiskTempByType` entry under the agent Disk Temp default, which switches
it off for every type. The alert carries the disk's serial, WWN and
`diskType`, like the health and wearout alerts.
- **One alert per disk.** `mergeHostAgentSMARTIntoDisks` marks a disk
that a still-reporting linked agent lists in its SMART report
(`AgentSMARTReported`, internal, never serialized). That disk is the
agent's: `CheckHost` alerts on it, and an open PVE alert closes with
`moved_to_agent`. A lapsed agent owns nothing, so the node's own current
readings keep alerting while its agent is silent.
- **Holds and clears.** Only a reading collected this poll is judged
(`diskinventory.TemperatureCollected`), so a retained value holds the
alert. An excluded device, or a switched-off policy, closes it.
- **Stability.** A new `proxmox-disk` alert delay defaults to 5s, so
warnings wait the same 300s noisy-gauge window as agent and TrueNAS disk
temperatures. Critical readings bypass it.
- **Config save.** `reevaluateActiveAlertsLocked` re-judges the alert
per disk type. Without this it would fall through to guest thresholds,
which have no disk temperature, and resolve on every save. An alert
without a recorded type uses the lowest per-type trigger.
- **History.** `ProxmoxPhysicalDiskAlertIdentifiers` lists the new
`<ref>::metric-threshold:diskTemperature` identifier, so resource
history owns its rows by recorded hardware identity, as #2646 does for
health and wearout.
- **Copy.** The Agents "Disk temperature by type" card says it covers
agent, TrueNAS and Proxmox disks.

## Evidence

Private mock stack (8 PVE nodes) with a local-only overlay pinning PVE
disk readings:

| Disk | Physical Disks | Alert |
|---|---|---|
| pve1 NVMe 63°C | Healthy | none |
| pve2 SATA 56°C | Running Hot | warning, trigger 55, after the 300s
window |
| pve3 NVMe 72°C | Running Hot | warning, trigger 70, after the 300s
window |
| pve3 SAS 63°C | Healthy | none |
| pve3 SATA 66°C | Running Hot | critical at once |

The alert card reads: "Samsung 870 EVO 1TB (/dev/nvme2) · Temperature ·
Temperature 66°C, above the 55°C alert level · Clears once it drops to
50°C or lower and stays there for 5 minutes."

## Tests

- `internal/alerts/alerts_test.go`:
  - the policy (per type, raised trigger);
- ownership with `moved_to_agent`, the hold without a reading,
exclusion, the off switch;
  - the stability window;
  - re-judging on save, including the lowest-trigger hold;
  - history identifiers.
- `internal/monitoring/physical_disk_roundtrip_test.go`: the marker for
reporting agents only (not a lapsed one), the SMART fallback path, and
that `checkPhysicalDiskAlerts` passes collected readings, ownership and
exclusion.
- `internal/unifiedresources/store_test.go`: a temperature alert row is
owned by its disk and still found by a read by its reference.
- `internal/models/deepcopy_test.go`: the marker survives cloning and
stays out of JSON.
- `ThresholdsTable.test.tsx`: the card copy.
- Ten wiring points were mutation-checked (marker, lease condition,
collected reading, ownership, exclusion, re-judge branch, lowest
trigger, resolution, history identifier); each removal fails a test.

## Second opinion

- Fixed: an offline agent's retained SMART rows kept ownership, so
neither side alerted while the agent was silent. Ownership now needs a
reporting lease.
- Kept, documented in `alerts.md`: on the first poll after an agent is
newly linked to a disk with an open PVE alert, both alerts can be open
until the next disk poll. This is the same window as the node-to-agent
usage alert handover.

## Not in this PR

- Like the health and wearout alerts, a disk that leaves the inventory
keeps its temperature alert until the 24-hour stale sweep.
- Per-host Disk Temp overrides do not reach PVE-only disks, which have
no agent host.
2026-10-07 21:59:39 +01:00
rcourtman b814ccdd81 Count down to future Patrol times instead of reading just now (#2702)
`formatRelativeTime` is past-only: a time ahead of now reads "just now"
(compact) or "0s ago". Three Patrol lines handed it future times, so
each read wrong for its whole duration:

- Attention detail, suppressed issue: "Returns to active attention just
now." (also for an issue marked reviewed, which expiry hands back to
Reviewed, not active attention)
- Findings, will-fix-later: "Reminding just now"
- Findings, snoozed: "snoozed until just now"

Supersedes #2613, rebuilt on main now that #2598 has landed (#2613 was
stacked on two early #2598 commits whose contract wording main has since
replaced).

## Change

- `formatTimeUntil` in `utils/format.ts`, beside `formatRelativeTime`:
counts down ("in 3h", "in 1d"), rounds to the nearest unit (a 24-hour
suppression set a moment ago reads "in 1d", not "in 23h"), and returns
`dueText` (default "now") once the time arrives. `formatRelativeTime`
stays past-only and its doc points future times at the new helper.
- The three lines use it with the shared relative-time clock
(`useRelativeTimeNow`), since none of those times change while the panel
stays open, and keep the exact time on hover.
- Suppression line: "Returns to active attention in 1d." for an open
issue, "Suppression ends in 1d. It stays reviewed." for one marked
reviewed, "Suppression has ended." once due. It takes its words from
`getAttentionSuppressionEndPresentation` (added on main by 844e1dbb97
for the early-end button and duration field), so the countdown, the
button and the duration label always share one reviewed/unreviewed
split. An unparseable expiry renders no line.
- Findings: "Reminding in 7d", then "Reminder overdue". The label, the
Overdue commitments chip and its filter all read one overdue id list
sampled on the shared clock, so they agree even for a row that mounts
between ticks (it reads "Reminder due" until the next tick), and a tick
that moves no deadline past now does not rebuild open rows. The reminder
hover no longer promises the finding returns only "if it is still
tripping"; the hourly reminder sweep brings it back regardless.
- Snooze: "snoozed, returns in 49m", then "snooze ended" until the next
findings read drops the snoozed status (the findings API sends no
status; the frontend store derives `snoozed` from `snoozed_until` on
each read).
- Audit of every `formatRelativeTime` /
`formatPlatformTableRelativeTimeValue` caller: no other future
timestamps. The autopilot and alert snooze expiries already show
absolute times.

The existing countdowns were not a fit: `CountdownTimer` runs its own
one-second interval with no day unit (a 7-day suppression would read
"167h 59m"), and the replication Next sync helper is private to that
table.

Contract notes sit mid-file: the frontend-primitives note follows main's
wall-clock sentence and precedes the read-time age exception (now opened
as "The relative-age rule has one deliberate exception"), away from the
paragraph #2600 adds after that exception.

## Verification

- Unit and component: `format.test.ts`, `FindingsPanel.test.ts`,
`FindingsPanel.links.test.tsx`, `PatrolAttentionWorkbench.test.tsx`. The
countdown tests fail against main's components; the new between-ticks
test fails against the first draft of this rebuild (the row said
"Reminder overdue" with no chip). Patrol, AI, alerts, utils and pages
suites pass apart from `agentInstallCommand.test.ts`, which needs Linux
`stat`; `tsc --noEmit` is clean.
- Live, on a private mock stack built from this tree at 1440 and 390,
with the websocket held (server frames dropped, heartbeat answered),
every GET replayed from cache and the page clock stepped one tick at a
time: an unreviewed and a reviewed suppressed issue counted down 1d →
20h → 29m → "Suppression has ended." with the matching end button; the
reminder went "Reminding in 7d" → "Reminder overdue" with the Overdue
chip on the same tick and findings reads still pending; set 5 seconds
past the deadline with no tick it read "Reminder due" with no chip, and
both moved together on the next tick; the snooze went "in 49m" → "in 9m"
→ "snooze ended", and the next findings read moved it to Active. With
main's components swapped back under the same Vite, the lines read "just
now" and stayed frozen. Receipt:
`frontend-modern/browser-verification/82459c28d2633b61.json`.
- Two second-model review rounds; the first found the between-ticks
disagreement and four wording nits, the second confirmed the fix on
every reminder path and found two nits, both applied.
2026-10-07 21:52:48 +01:00
rcourtman 0b6473affc Fill the table's visible width with phone drawers (#2703)
Phone and tablet table drawers capped their sticky content at
`calc(100vw - 3.5rem)`, a guess at the surrounding page chrome. On pages
with less chrome than that, every drawer stopped short of the table and
left an empty strip on the right: 27px at 390px, measured on the
Availability checks, Proxmox node and guest, Docker host and container,
Kubernetes node and Machines drawers. At tablet widths a table wider
than its shell got a drawer wider than the visible area (712px in
695px).

The table scroll shell already declares `container-type: inline-size`
(the responsive column priorities use it), so `InlineDetailTableRow` now
caps the default content shell at `100cqi`, the shell's own visible
width. Measured live: content equals the visible table width at 320,
390, 768 and 1000px, with no right gap; desktop keeps the static
uncapped layout. The availability drawer's hostname now fits instead of
truncating. Callers passing their own `contentClass` are unaffected.

Not changed here: `.table-fixed td { overflow: hidden }` makes the
sticky offset inert, so a sideways-scrolled table still moves its drawer
(same before and after). Long unwrapped identity chips that were already
clipped are clipped less now; wrapping them is a separate follow-up.

Proof: `InlineDetailTableRow.test.tsx` and the shared-primitive
guardrail pin the new cap and reject the old one; the guardrail already
pins the scroll shell's `container-type`. Browser receipt covers five
routes at 320 to 1440px, before and after.
2026-10-07 21:42:50 +01:00
rcourtman 3fb267333d Require POST for Assistant session mutation routes (#2670)
The session router dispatches `/api/ai/sessions/{id}/abort`,
`summarize`, `fork`, `undo` and `redo` by path alone, and none of those
handlers checked the HTTP method, so a GET or HEAD cancelled a running
turn, compacted or rewound a transcript, or persisted a fork.

- **Demo mode:** `DemoModeMiddleware` admits every GET/HEAD as a read,
so a public-demo session could mutate Assistant sessions despite the
read-only guard.
- **CSRF:** `CheckCSRF` skips GET/HEAD, and session cookies default to
SameSite=Lax, which browsers send on cross-site top-level GET
navigations. A link carrying a session ID could therefore trigger these
mutations for a signed-in user.

## Change

- `requireAssistantSessionMutationMethod` in
`internal/api/ai_handler.go`: abort, summarize, fork, undo, redo and
steer admit only POST and answer anything else with `405` + `Allow:
POST` before resolving the Assistant service. Steer already refused
non-POST but sent no `Allow` header; it now uses the same guard.
- The guard sits in the handlers, matching how chat, steer and the
approval approve/deny handlers already own their method contracts.
- `messages` (GET) and the unsupported `diff`/`revert`/`unrevert` routes
are unchanged. Session rename/delete and the collection route already
dispatch on the method.
- Callers: `frontend-modern/src/api/aiChat.ts` uses POST for all six;
pulse-mobile's abort call and its OTA compatibility probe use POST.

## Proof

- `TestAssistantSessionMutationsRequirePost`: GET/HEAD/PUT/DELETE get
405 + `Allow: POST` for every mutation, with no mutation call and no
`IsRunning` check reaching the service. POST still dispatches.
- `TestAssistantSessionReadSubresourcesKeepTheirMethods`: GET `messages`
returns 200 and GET `diff` returns 501, with no mutation either way.
- `TestDemoModeRouterRejectsAssistantSessionMutationsOverSafeMethods`:
drives `NewRouter(...).Handler()` with `DemoMode` on. GET/HEAD to every
mutation pass the demo guard and get 405, POST stays 403, nothing
mutates, and GET `messages` still reads.
- Negative controls via `go test -overlay`: on main's `ai_handler.go`,
every non-POST case fails and the demo test reports `GET abort status =
200`. With abort's guard moved after the `IsRunning` check, the ordering
assertion fails.
2026-10-07 21:35:21 +01:00
rcourtman 1ac600ec2b Judge filesystem alerts by their own threshold on config save (#2700)
Stacked on #2696, which is stacked on #2692. The unique commit is the
last one, 539eadfab2.

A config save re-judges every active alert
(`reevaluateActiveAlertsLocked`). Filesystem usage alerts were re-judged
against thresholds their evaluator never uses:

- **Agent filesystems** (`agent:<host>/disk:<label>`) were judged by the
host disk threshold. `CheckHost` judges them by the filesystem's own
override, then the `DiskFillByType` threshold for an inferred hardware
type, then the host threshold.
- **Guest filesystems** (`<guestID>-disk-<key>`) were resolved by the
filesystem's resource ID. That ID matches no `guest-disk:` override and
names no guest, so the guest's own override was missed too.

Example: a filesystem override with trigger 70 raises an alert at 80
under a default of 90. Saving any unrelated alert setting resolved the
alert and sent a recovery notification, and the next poll raised it
again as a new alert.

`CheckHost` and `CheckGuest` now resolve a filesystem's threshold
through `hostDiskUsageThresholdNoLock` /
`guestDiskUsageThresholdNoLock`, and reevaluation calls the same
helpers:
- `resolveHostAlertThresholdsNoLock` applies the host helper for agent
filesystem alerts.
- The new `resolveGuestAlertThresholdsNoLock` resolves a guest
filesystem alert's guest by the ID `parseGuestAlertIdentity` reads from
its resource ID.

Both helpers return override and per-type thresholds as owned copies, so
#2696's rule that evaluation never normalizes the live config still
holds.

`TestConfigSaveJudgesFilesystemAlertsByTheirOwnThreshold` (in
`threshold_resolution_shared_test.go`) covers four cases:
- an agent filesystem override
- an NVMe fill threshold
- a guest filesystem override
- a guest-wide override on a filesystem alert

With `host.go`, `guest.go` and `config_runtime.go` overlaid by their
origin/main or #2696 copies, all four fail at the unchanged save. `go
test ./internal/alerts/...` passes, and the new test plus #2696's
`TestAlertEvaluationLeavesTheLiveConfigToTheLock` pass under `-race`.

Known limits, unchanged by this PR:
- Custom rules that filter on live metrics still see no readings during
reevaluation.
- `pulse-relaxed` is not applied during reevaluation.
- The mock generator's dash-separated guest IDs don't parse as guest
identities.
2026-10-07 21:10:14 +01:00
rcourtman 77d2119792 Remove unread Patrol summaryStats and its audit rule (#2690)
Stacked on #2686, which sits on #2682 and #2674; its own change is the
top commit, 6d8dce2cae. It merges cleanly with main and with every other
open PR touching these files.

`usePatrolIntelligenceState()` still returned `summaryStats`, a closure
counting critical, warning and active Patrol findings plus fixed
outcomes. Its only reader was `PatrolIntelligenceSummary.tsx`, deleted
in 27ba85ddc6. A type-checker scan of the whole program (src and tests)
finds no reference to the member outside the hook's return object, and
nothing spreads, iterates or index-reads the returned state.

- Removes the closure and its return entry.
- Removes the `canonical-patrol/no-local-summary-card-presentation`
audit rule. It matched only the deleted card's tint ternaries on
`summaryStats()`, and it pointed authors at `patrolSummaryPresentation`,
which no longer holds summary-card presentation. The three
`not.toContain("summaryStats()...")` guards that pinned the same strings
go too.
- Takes `patrolSummaryPresentation.ts` off the audit allowlist. Its
remaining presenters trip no rule, and a planted violation is now
reported.
- Contract notes in patrol-intelligence, api-contracts and
frontend-primitives. The section-owner sentence that named the deleted
summary surface now names the live tab panels and scopes the
no-own-reads rule to the hook-backed surfaces.

Runtime neutrality: of 187 dist files, 94 are byte-identical and 92
differ only in chunk-hash names and SRI strings. In the AIIntelligence
chunk, after normalising hashes and short identifiers, the only residual
is the removed closure and its return entry. The bundle-size check
passes. The browser receipt covers the Patrol Inbox, Activity and
Protection tabs at 1440 and 390 px.

The `Contract-Neutral` trailer covers only the platform-support-manifest
proof that the audit script's path policy names. This change leaves that
projection check untouched.
2026-10-07 20:42:56 +01:00
rcourtman 48c2d6cdce Stop Patrol fetching the correlation list nobody reads (#2677)
The Patrol page fetched `GET /api/ai/intelligence/correlations` on every
mount, every 60 s poll, every return to the tab and on Retry, and the
store's dashboard bundle fetched it again for FindingsPanel's refresh.
Nothing has read the list since the Patrol supporting-context block was
removed in 27ba85ddc6; the resource drawer reads per-resource
correlations from the resource-intelligence payload.

## Change

- `stores/aiIntelligence.ts`: remove the correlation signal,
`correlations` getter, `correlationsSignal`, `loadCorrelations()`, its
org-switch reset, and its entry in `loadDashboardData()`.
- `features/patrol/usePatrolIntelligenceState.ts`: drop the read from
the Retry path's background supporting reads.
- `api/ai.ts`: remove `AIAPI.getCorrelations` (no other caller). The
backend route stays as an API surface behind `ai:execute`.
- Tests: the store bundle test now pins every AIAPI read the bundle
makes; the manual-run test stalls the post-start refresh through a
remaining read so it still proves "Starting" clears without waiting
(negative controls: awaiting the refresh, or removing it, both fail the
test). Dead correlation route stubs removed from six E2E specs.
- Contract prose in api-contracts, patrol-intelligence,
unified-resources, ai-runtime and frontend-primitives no longer says the
Patrol page or store loads or renders correlations or recent changes.
The remaining stale Patrol correlation paragraphs (patrol-intelligence
~L2135/~L2197, ai-runtime ~L6854) are rewritten by #2674; the two PRs
split hunks by agreement and merge cleanly together.

`CorrelationsResponse` stays for now:
`patrolInvestigationContextModel.ts` still imports it until #2674 lands.
It goes in a follow-up once both are on main.

## Evidence

- Demo-mode stack, headless Chromium at 1440x900 and 390x844:
correlation requests go from 1 to 0 per Patrol load, browser reload, 60
s poll and tab hide/show. Every other `/api/ai/*` read per phase is
identical; no console errors. Re-run on the final base.
- Production build: the route string is gone from the bundle;
`check-bundle-size.mjs` passes with no chunk renamed (JS gzip -0.18 kB
on the same base).
- `tsc --noEmit` passes; vitest for src/stores, src/api,
src/features/patrol, src/components/AI and the Patrol page test passes;
contract, registry and status audits pass.
2026-10-07 19:49:15 +01:00
rcourtman 71a723abb1 Read controller-member disk history at its written key (#2694)
## Problem

A physical disk behind a RAID controller (target such as `megaraid,3`)
with no usable serial or WWN had its SMART history written under one key
and read under another, so its temperature and SMART charts stayed
empty.

- Host agent: writes under `HostSMARTDiskMetricID`, e.g.
`host-pve:sdd@ctrl0/megaraid,3`.
- Proxmox: writes under `PhysicalDiskMetricID`, which returns
`ProxmoxPhysicalDiskSourceID`, e.g.
`pve-node1--dev-sdx:sdx@/megaraid,0`.
- Reader: `BuildMetricsTarget` passed those source IDs, which already
name the member, to `PhysicalDiskMetaMetricID`. That function appended
the topology again:
`host-pve:sdd@ctrl0/megaraid,3:sdd@ctrl0/megaraid,3`. The same happened
in a registry rehydrated from persisted resources.

## Fix

`PhysicalDiskMetaMetricID` now follows the Proxmox writer. A fallback
that already ends in the member's topology is returned unchanged. The
topology is appended only to a fallback without it:
- a Proxmox source ID from before members were scoped, which
`PhysicalDiskMetricID` scopes the same way;
- the canonical resource ID that `PhysicalDiskView.MetricResourceID`
falls back to.

TrueNAS disks carry no controller target and are unaffected.

## Existing history

- **SMART:** no SMART writer used the doubled key, so samples already
stored become readable without a migration.
- **Disk I/O:** a linked node's agent with no SMART reading for a
member, such as a standby row, files the device's I/O under the Proxmox
disk's metrics target. That target was the doubled key. The I/O now
lands beside the member's SMART history. Samples already stored under
the doubled key are left to age out: nothing reads them, and they are
the block path's counter filed under its one merged member.
- Mock seeding keys these disks by `disk.ID`, the single-scoped key, so
it already matched.

## Proof

- `TestIdentitylessControllerMembersReadTheirWritersHistory`
(`internal/monitoring/issue1595_collection_trust_test.go`) drives the
real writers: `writeHostSMARTMetrics`, `writeHostPhysicalDiskIOMetrics`
and the Proxmox `writeSMARTMetrics`. It then queries the store at
`MetricsTarget` for five members in a live registry and in one
rehydrated through a JSON round trip. The members are two agent members
sharing a path, a lone agent member with I/O, a Proxmox member, and a
standby member merged into its linked node's Proxmox row. Before the fix
all ten targets read the doubled key.
- `TestPhysicalDiskMetaMetricIDScopesAControllerMemberOnce`
(`internal/unifiedresources/canonical_ids_types_test.go`) pins each
fallback shape against its writer: agent, smartctl-labelled agent,
Proxmox, legacy Proxmox, canonical resource ID, and a disk that is no
controller member. Before the fix, three cases failed with the doubled
key.

## Stacking

This branch sits on #2685, which sits on #2679, because both edit
`physical_disk_ids.go` and the proof file. It supersedes #2661, which
carried the same reader fix but conflicted with this stack and with
main.
2026-10-07 19:31:12 +01:00
rcourtman 843daf7ae3 Remove dead Patrol assessment and save-failure handoffs (#2682)
Stacked on #2674 (branch `refactor/drop-patrol-context-summary`); this
branch contains that commit.

## What

Removes the two Patrol-to-Assistant handoff builders in
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
that no production code calls:

- `buildPatrolAssessmentAssistantHandoff`: its caller, the summary
card's Discuss handler, went in eac7dfe9ef (2026-05-15).
- `buildPatrolConfigurationFailureHandoff`: its last caller went in
27ba85ddc6 (2026-06-23).

With them go `PatrolInvestigationContextSummary`, their input and output
types, and the private helpers only they used. The model drops from
2,326 to 1,257 lines.

## Why it is safe

- A TypeScript compiler export graph rooted at `src/index.tsx` shows no
reachable importer of either builder, and `git grep` finds only their
own tests.
- Production builds of the parent tree and this tree are byte-identical
across all 187 dist files (`shasum`). `check-bundle-size.mjs` passes.
- The Assistant drawer still restores saved `patrol_assessment` and
`patrol_configuration_failure` sessions. That code
(`components/AI/Chat/index.tsx`, `workflowStarters.ts`,
`internal/ai/chat/session.go`) is unchanged.

## Tests

- The removed builders' cases are gone.
- Two cases covered branches the live finding handoff still uses, so
they now go through the live builders: a typed pending action stays
approval-bound without a legacy approval id, and the resource node
qualifier plus long-line truncation.
- v8 coverage of the surviving model code matches the baseline from
before the removal. The one exception is a defensive guard the live
caller cannot reach.

## Contracts and docs

Eight contracts changed: `patrol-intelligence`, `ai-runtime`,
`api-contracts`, `frontend-primitives`, `security-privacy`,
`storage-recovery`, `agent-lifecycle` and `unified-resources`.

- No contract describes either handoff as a live producer any more.
Statements about restoring saved sessions of those kinds stay.
- Some clauses described a rendered Patrol summary card, assessment
strip or readout, score chip, header trust line, or inline save-failure
surface. They are now conditional or say what renders instead.
- The run-history handoff clauses now say the browser sends the briefing
and safe run metadata, and `internal/ai/patrol_assistant_handoff.go`
rebuilds the model context from the run ID.
- `docs/AI.md` and its public mirror pointed users at a legacy
operational score in a collapsed supporting-context section. Neither
exists any more. The section now points at **Activity > Finding options
and history > History**.
- `ai_runtime_docs_policy_test.py` is the registered proof for
`docs/AI.md`. It now pins that location and rejects the retired claim.
It also had a stale pin that has failed on main since 2789a15fe5; that
pin now matches the current Patrol mode API wording.

Prose about the remaining test-only summary presenters in
`utils/patrolSummaryPresentation.ts` is left for a separate follow-up
that removes those presenters.

## Browser verification

Receipt `frontend-modern/browser-verification/6e6cbc66d62863d1.json`.
Checked on an embedded production build on a private demo mock stack, at
1440x900 and 390x844:

- Inbox "Explain with Assistant" opens the drawer with the Pulse Patrol
attention briefing.
- The run-history "Discuss" button opens the drawer with the "Patrol run
attached" briefing.
2026-10-07 18:52:17 +01:00
rcourtman 2eef42c512 Keep Off alert defaults off after saving (#2684)
## Summary

Switching a metric Off in a **Global Defaults** row (Alerts >
Thresholds) did not stick. After Save and a reload the metric was back
On at its factory threshold and kept alerting. This affected every
section whose factory threshold is positive: Machines CPU, memory, disk
and disk temperature, Proxmox node temperature, PBS, Docker, Kubernetes,
TrueNAS, vSphere and storage.

The editors stage `-1` for Off, and the save payload passed it through
as `{trigger: -1}`. Backend config normalization reads a negative
default trigger as unset and restores the factory value. Guest and node
CPU, memory and disk escaped only because nothing normalizes them.

- `buildAlertsConfigurationPayload` now writes every Off default as
`{trigger: 0, clear: 0}`, the value the engine and normalization both
read as off. Per-resource overrides are serialized separately and keep
`-1`, which the override path stores as written. A `-1` already stored
on a guest or node default becomes `0` on the next save.
- The Global Defaults **Custom** badge now treats `-1` and `0` as the
same Off value. Without that, a fresh install showed Custom on
Kubernetes, TrueNAS Systems and vSphere, and VMs & Containers turned
Custom after any save, though nothing had changed. An unset factory
value stays distinct from Off: an unset TrueNAS disk temperature follows
Disk temperature by type.
- A row's custom-threshold marker still compares raw values. It drives
the row's revert control and override removal, so a stored `-1` override
must stay visible against an Off default. A test pins that.
- The alerts contract no longer claims Off is written as `-1`
everywhere.

## Verification

- New payload test: every section's Off default is written as `{0, 0}`
and reads back Off. It fails on `main` with `guestDefaults.cpu: expected
{ trigger: -1 }`.
- New backend test: a `{0, 0}` default stays off through `UpdateConfig`
in every section, and a host at 99% raises no CPU, memory or disk alert
when the agent defaults are off. Both fail when the defaults are written
as `-1`.
- Live on a private mock stack. On `main`, Proxmox CPU and Temp switched
Off saved as `{-1}` and `{80, 75}`, and after reload Temp read On. With
the fix:
- Machines CPU at 1% raised 8 alerts. Switching it Off stored `{0, 0}`
and resolved all 8.
  - Proxmox Temp Off resolved the node temperature alert.
  - Nothing re-fired over two minutes.
- Values stayed Off after a backend restart and a reload. Machines,
Proxmox, Kubernetes, TrueNAS and vSphere were each checked at desktop or
phone width.
- Custom badge on a fresh install: no Proxmox, Kubernetes, TrueNAS or
vSphere group reads Custom, and after an unrelated save only the group
actually changed does.
2026-10-07 18:47:09 +01:00
rcourtman dace9295c6 Remove exports orphaned by the summary component deletions (#2671)
Stacked on #2662 (its commits are ancestors of this branch, so this PR
shows them until #2662 merges).

The May cleanup deleted InfrastructureSummary, StorageSummary, the
shared summary table and the SummaryMetricCard, InteractiveSparkline,
DensityMap and SummaryPanel primitives, but left the helpers only they
called. This removes them:

- `getMetricsChartKeyCandidatesFromResource` (utils/agentResources.ts)
- `getNormalizedIdentityLookupVariants` and
`getAgentLikeIdentityAliases` (utils/resourceIdentity.ts)
- `getPBSStatusIndicator` (utils/status.ts)
- `useSummaryContextualFocusState`
(components/shared/contextualFocus.ts), plus
`filterSummarySeriesByGroupScope`, `resolveSummaryCardInteractionState`
and `SummaryCardInteractionState` that only it used
- the deprecated `ChartsAPI.getInfrastructureCharts` delegate; its
branch tests now exercise `getInfrastructureSummaryCharts`

Proof of deadness: a TypeScript import/export graph from `src/index.tsx`
finds no production importer of any removed symbol, and a repo-wide grep
finds no remaining reference. The production build differs from its
parent only in the shared chunk that carries ChartsAPI, which loses the
delegate's static method (99 bytes); after normalising chunk hashes, SRI
hashes and minified identifiers every other file is identical. Tests:
`src/utils`, `src/components/shared`, `src/api`, `src/features`,
`src/hooks`, `src/__tests__`, Workloads, Storage and Infrastructure
suites pass (only the known Mac-only `agentInstallCommand` failures).

Contracts: frontend-primitives, storage-recovery and
performance-and-scalability still described the deleted summary cards,
sparkline, density map, summary panel, sticky summary section and Jump
to row button as live owners, and credited contextualFocus.ts with the
removed hook's series filtering. Those clauses now name the live owners
(summaryCardInteraction.ts behind summaryTableFocus.ts, the storage
summary cache behind the pool Growth column, TooltipPortal for the mini
sparkline and backup activity chart tooltips), become conditional on a
summary strip returning, or go. api-contracts and unified-resources
record the removed chart client and chart-key helper.

Browser pass (embedded production build, demo mock stack, 1440x900 and
390x844): Proxmox node-to-guests reveal, guest row hover emphasis and
inline detail reveal, storage Growth column, pool row hover and inline
detail, pool-group hover preview, pin and Escape clear. Receipt:
`frontend-modern/browser-verification/232e5c23490f8cd4.json`.
2026-10-07 18:02:21 +01:00
rcourtman 864a088db6 Fill the table's visible width with phone drawers
Inline table drawers below the lg breakpoint capped their sticky content
at calc(100vw - 3.5rem), a guess at the page chrome. Pages with less chrome
than that left an empty strip beside every drawer (27px at 390px on the
Availability checks, Proxmox, Docker, Kubernetes and Machines tables), and
a table wider than its shell at tablet widths got a drawer wider than the
visible area.

The table scroll shell is already a size container, so the content now caps
at 100cqi: exactly the table's visible width at every width, measured live
at 320, 390, 768 and 1000px. Desktop keeps the static uncapped layout.
2026-10-07 17:52:10 +01:00
rcourtman e747f2e57c Count down to future Patrol times instead of reading just now
formatRelativeTime is past-only: any time ahead of now reads "just now"
(compact) or "0s ago", which is right for clock skew on an observed
time. Patrol handed it three future times, so each read wrong for its
whole duration: a suppressed issue said "Returns to active attention
just now.", a will-fix-later finding said "Reminding just now", and a
snoozed finding said "snoozed until just now".

formatTimeUntil in utils/format.ts is the future counterpart. It counts
down ("in 3h", "in 1d"), rounds to the nearest unit so a 24-hour
suppression set a moment ago reads "in 1d" rather than "in 23h", and
returns "now" (or the caller's dueText) once the time arrives. The three
Patrol lines use it with the shared relative-time clock, because none of
those times change while the panel stays open, and keep the exact time
on hover.

The suppression line names the state expiry restores. An open issue
reads "Returns to active attention in 1d."; one marked reviewed reads
"Suppression ends in 1d. It stays reviewed.", because expiry restores
Acknowledged, which is not active attention; a passed expiry reads
"Suppression has ended.". The countdown takes its words from
getAttentionSuppressionEndPresentation, which already words the
early-end button and the duration field by the same reviewed split, so
all three agree.

A reminder reads "Reminder overdue" once one overdue id list, sampled on
the shared clock, holds it. The Overdue commitments chip and filter
count the same list, so the label, chip and filter agree even for a row
that mounts between ticks (it reads "Reminder due" until the next tick),
and the list only notifies when its ids change, so a tick that moves no
deadline past now does not rebuild open rows. The reminder hover no
longer promises the finding returns only "if it is still tripping": the
hourly reminder sweep brings it back regardless. An ended snooze reads
"snooze ended" until the next findings re-read drops the snoozed status.

The existing countdowns did not fit: CountdownTimer runs its own
one-second interval and has no day unit (a 7-day suppression would read
"167h 59m"), and the replication Next sync helper is private to that
table with sync-specific overdue tones.

An audit of every formatRelativeTime and
formatPlatformTableRelativeTimeValue caller found no other future
timestamps; the autopilot and alert snooze expiries already show an
absolute time.

This rebuilds PR 2613 on main after PR 2598 landed. PR 2613 was stacked
on two early PR 2598 commits whose relative-age contract wording main
has since replaced (every read of the shared clock now returns the wall
clock), so the future-time note sits under main's wording and before the
read-time age exception, and that exception now opens "The relative-age
rule" so it does not read as an exception to the future-time note.
2026-10-07 17:47:47 +01:00
rcourtman 539eadfab2 Judge filesystem alerts by their own threshold on config save
A config save re-judges every active alert. Agent filesystem alerts
were re-judged by the host disk threshold, ignoring the filesystem's
own override and the per-type DiskFillByType threshold CheckHost uses.
Guest filesystem alerts were resolved by their "<guestID>-disk-<key>"
resource ID, which matches no guest-disk override and names no guest,
so the guest's own override was missed too. A filesystem override of
70 raised an alert at 80 under a default of 90; saving any alert
setting resolved it and sent a recovery, and the next poll raised it
again as a new alert.

CheckHost and CheckGuest now resolve a filesystem's threshold through
hostDiskUsageThresholdNoLock and guestDiskUsageThresholdNoLock, and
reevaluation calls the same helpers: resolveHostAlertThresholdsNoLock
for agent filesystem alerts, and a new resolveGuestAlertThresholdsNoLock
that resolves a guest filesystem alert's guest by the ID
parseGuestAlertIdentity reads from its resource ID. The helpers return
override and per-type thresholds as owned copies, so evaluation still
never normalizes the live config.

TestConfigSaveJudgesFilesystemAlertsByTheirOwnThreshold covers an
agent filesystem override, an NVMe fill threshold, a guest filesystem
override and a guest override. With host.go, guest.go and
config_runtime.go overlaid by their origin/main or #2696 copies, all
four fail: the unchanged save resolves the alert.

Stacked on #2696.
2026-10-07 17:36:34 +01:00
rcourtman 7eb32f3181 Keep a low alert default's clear below its trigger (#2678)
## Problem

Setting an alert global default to a positive trigger of 5 or less, for
example Machines CPU at 1% in Alerts > Thresholds, stored
`agentDefaults.cpu` as `{trigger: 1, clear: 75}`, a clear far above its
trigger.

The thresholds page sends `clear = max(0, trigger - 5)`, which is 0 for
those triggers. `NormalizeAgentDefaults` derived `trigger - 5`, found it
non-positive, and fell back to the factory clear (75/80/85).
`ValidateHysteresisThresholds` never checked agent CPU, memory or disk,
so the pair survived. PBS CPU/memory, node temperature and
`internal/config/persistence.go` had the same fallback. On the save
path, validation repaired PBS and node temperature, but
`LoadAlertConfig` still turned a stored node temperature clear of 0 into
75; at startup `UpdateConfig` repaired that in memory.

Per-type `DiskFillByType` / `DiskTempByType` entries were worse: any
clear ≤0 reset the whole pair. A SATA disk temperature trigger of 4 from
the thresholds page was stored as 55, and an API-written fill entry of
`{88, 0}` became `{90, 85}`.

## What a user saw

- **Firing and resolving did not change for these pairs.**
`buildCanonicalMetricSpec` drops a clear at or above the trigger, and
the reducer then clears at the trigger. That held both before and after
this change. There was no flapping and no alert that never re-armed.
- `/api/alerts/config` and `alerts.json` served the factory clear above
the trigger.
- Firing agent alerts carried it as `metadata.clearThreshold`: 75 on a
1% CPU alert, and 85 on a disk alert still firing at 81.8%.
- A per-type disk trigger of 1–5 was silently replaced by the type
default.
- Machines metric bars: `resolveThreshold` in `metricThresholds.ts`
clamps the warning level to `min(clear, trigger)`, so a 1–5% agent
trigger had no warning band. Readings below the trigger, including 0,
now show amber instead of green, which matches guest, node, Docker and
Kubernetes bars at the same setting. This one is read from the code; I
did not exercise it in a browser.

## Fix

- PBS, node temperature, agent and per-type disk defaults now use
`normalizeThresholdPointer`, the normalizer Kubernetes, TrueNAS and
vSphere already use. A missing clear sits five points under the trigger,
floored at 0. A clear at or above the trigger is repaired. Zero still
means off for global defaults, and nil or negative still takes the
factory default. For the canonical per-type keys (the types the page
edits), a non-positive trigger still resets to the type default, because
the page cannot represent a per-type Off; other per-type keys are stored
as written, as before.
- `internal/config/persistence.go` drops its duplicate
agent/node-temperature and storage helpers and calls the same normalizer
through a new `NormalizeHysteresisThreshold`. A config saved before this
fix loads repaired.
- Agent CPU/memory/disk/disk temperature are added to
`ValidateHysteresisThresholds`, as every other family already is.
- One deliberate behaviour change for invalid pairs above the margin: an
API-written `{50, 80}` is repaired to `{50, 45}`, so it now has a real
recovery band where it used to clear at the trigger. This matches what
`EnsureValidHysteresis` already does for every other family.
- An alerts contract clause records the rule.

## Verification

- `internal/alerts/config/normalize_low_trigger_clear_test.go` runs
every default family and both per-type maps through the `UpdateConfig`
normalizer chain.
- `TestUpdateConfigKeepsLowTriggerClearBelowTrigger` pins the saved
config and a firing alert's `clearThreshold`.
- `persistence_test.go` checks the written `alerts.json` itself as well
as load repair.
- All new tests fail against the previous normalizers. Coverage cases
that pinned the old fallback (including one labelled "suspected source
bug") are updated.
- `go test ./internal/alerts/... ./internal/config/ ./internal/ai/`
passes.
- Live, on private mock stacks (baseline binary vs this branch): I saved
the payload the thresholds page sends, then read back
`/api/alerts/config`.
- Baseline: agent `{1,75}`, `{3,80}`, `{5,85}`; `diskTempByType.sata`
`{55,50}` for a 4°C trigger; firing agent alerts reported
`clearThreshold` 75/80/85.
- This branch: every pair `{t, 0}`, `diskTempByType.sata` `{4, 0}`, and
alerts reported 1/3/5.
- After a restart, this branch also repaired a hand-written
`alerts.json` holding `{1,75}` and node temperature `{2,75}`.
2026-10-07 17:22:55 +01:00
rcourtman 71a470129f Stop alert evaluation writing live thresholds outside the lock
Host and guest evaluation filled in the Clear of a per-disk override's
threshold after dropping m.mu, writing through the live config's pointer.
GetConfig clones that pointer under the read lock, which does not exclude
the writer, and the saved override silently took whatever the last poll
filled in. Docker evaluation pointed into m.config.DockerDefaults, and read
the restart-loop and memory-limit settings, with no lock held while
UpdateConfig replaced m.config.

Clone the override's Disk threshold under m.mu before normalizing it,
resolve Docker container thresholds from cloned defaults plus the override
under the lock, and read the restart-loop and memory-limit settings under
it. Drop getThresholdForMetricFromConfig: nothing has called it since the
v6 split, and it normalized in place.

TestAlertEvaluationLeavesTheLiveConfigToTheLock runs host, guest and Docker
evaluation against GetConfig() and UpdateConfig(GetConfig()) under -race.
With host.go, guest.go, docker.go and metric_runtime.go overlaid by their
origin/main copies it fails: the live override's clear reads 85, and -race
reports every site above.

Stacked on #2692, which made GetConfig return an owned copy.
2026-10-07 17:19:43 +01:00
rcourtman 5c55197900 Remove test-only Patrol context summary helpers (#2674)
Removes two exports from
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
that only tests still called, and corrects the contract prose that
described the removed Patrol supporting-context surface as live.

## Why

`buildPatrolInvestigationContextSummary` (with its input type) and
`selectPatrolSupportingRecentChanges` lost their last production caller
in 27ba85ddc6 (2026-06-23). That commit removed the Patrol page's
supporting-context block and its `Details` toggle for recent changes,
learned correlations and policy coverage. Since then, only their own
tests and a raw-source guard referenced them.

## Evidence

- A TypeScript-compiler import graph rooted at `src/index.tsx` on
current main shows no reachable importer of either export. `git grep`
finds only the tests and the guard.
- Before/after production `vite build`: byte-identical across all 187
dist files, because Rollup had already tree-shaken the code.
`check-bundle-size.mjs` passes and `.bundlesize.json` is unchanged.
- Browser pass on an embedded build of this tree (demo-mode mock stack,
1440x900 and 390x844). None of these show recent-change, correlation,
policy-coverage or `Details` context UI: Patrol Inbox, Protection,
Activity, Finding options and history, the finding review panel, the run
findings snapshot and the selected run. The attention ("Explain with
Assistant") and run ("Discuss") handoffs from this module still open the
Assistant drawer with their briefings. Receipt:
`frontend-modern/browser-verification/eaf59cf8f93521c4.json`.
- `tsc --noEmit`, eslint and prettier pass. Vitest passes over
`src/features`, `src/utils`, `src/components/AI`,
`src/components/patrol`, `src/stores` and `src/pages`. The only failure
is `agentInstallCommand.test.ts`, which fails on the Mac regardless of
this change. `contract_audit.py`, `registry_audit.py` and
`status_audit.py` all pass.

## Guard

The raw-source guard in `frontendResourceTypeBoundaries.test.ts` pinned
the removed builder. It now does three things:
- It pins the export of
`buildPatrolAssistantFindingHandoffFromUnifiedFinding`, which the Patrol
hook imports.
- It checks the hook's import declaration with one regex, instead of the
symbol and the module path as separate strings. Moving the import to
another module now fails, and a single-line import still passes.
- The `policy-covered resource` copy pin is now a negative pin.

## Contracts

The patrol-intelligence, frontend-primitives, api-contracts and
ai-runtime clauses that described the removed surface now match the
code:
- Patrol renders no recent changes, correlations, posture or summary
card, and reads the AI summary only for overall health.
- There is no `Details` context affordance. One rule that still allowed
it contradicted an existing prohibition.
- The shared change and correlation cards carry no resource-link
default.
- Rules for a returning summary or recent-change normalization are now
conditional.

## Left for other PRs, already in progress

- The equally unreachable Patrol assessment and save-failure Assistant
handoff builders, their prose, and the retired summary-card and
assessment-strip clauses.
- The Patrol refresh path's unread `/api/ai/intelligence/correlations`
fetch, plus the two api-contracts clauses about it and the matching
unified-resources.md prose.
2026-10-07 17:14:56 +01:00
rcourtman ef45926b12 Merge commit 'cd4e564fbf' into fix/proxmox-disk-temperature-alerts 2026-10-07 17:06:42 +01:00
rcourtman cd4e564fbf Merge remote-tracking branch 'origin/main' into fix/disk-heat-remaining-judges 2026-10-07 17:05:10 +01:00
rcourtman 409c5482ee Read controller-member disk history at its written key
A physical disk behind a RAID controller (target such as megaraid,3)
with no usable serial or WWN had its SMART history written under one
key and read under another, so its temperature and SMART charts stayed
empty. The host agent writes under HostSMARTDiskMetricID and Proxmox
under PhysicalDiskMetricID, and both keys already scope the member
(host-pve:sdd@ctrl0/megaraid,3). BuildMetricsTarget passed that source
ID to PhysicalDiskMetaMetricID, which appended the topology again
(host-pve:sdd@ctrl0/megaraid,3:sdd@ctrl0/megaraid,3).

The reader now does what the Proxmox writer does: a fallback that
already names the member is returned unchanged, and only a fallback
without the topology gets it appended. That covers a Proxmox source ID
from before members were scoped and the canonical resource ID a view
falls back to. TrueNAS disks carry no controller target and are
unaffected.

SMART history needs no migration: no SMART writer used the doubled
key, so samples already stored become readable. A linked node's agent
with no SMART reading for a member, such as a standby row, files the
device's I/O under the Proxmox disk's metrics target, which was the
doubled key. That I/O now lands beside the member's SMART history, and
samples already stored under the doubled key are left to age out.

This carries forward the fix from PR 2661, whose branch conflicts with
the disk ID stack it now sits on, and proves it against the real SMART
and I/O writers in the live and rehydrated registries.
2026-10-07 17:01:52 +01:00
rcourtman ad1e34277d Stop alert config saves writing into the live config
SaveAlertConfig normalized the config it was handed in place, and its
callers hand it alerts.Manager.GetConfig(), a shallow copy whose maps
and threshold pointers were the running config's own. Alert activation
and the alert identity migration therefore wrote the live delay map and
threshold pointers outside the manager lock while evaluation read them.
The shallow copy also let UpdateConfig, which normalizes the override
map it is handed in place, rewrite the live overrides while
GET /api/alerts/config encoded another snapshot. A concurrent map read
or iteration plus a write is a fatal Go runtime error, so these paths
could kill Pulse.

AlertConfig.Clone is a typed deep copy. GetConfig returns one, so
callers own what they encode, persist and edit, and SaveAlertConfig
clones before normalizing, because a config just applied with
UpdateConfig is the live config. GetConfig has no per-resource caller
(once per poll cycle, backup check, request or Patrol run); a clone
with 1,000 overrides takes about 0.3 ms. A test fills every field and
fails when a new map, slice or pointer is not copied.

Persistence also stored and loaded an explicit 0 type-level alert delay
("no delay") as the 5-second factory delay, which the noisy-gauge rule
stretches to 300 seconds for memory and temperature, while the manager
keeps 0. The shared map write was what made the running manager agree
with the file; without it, memory would say 0 and disk 5. Persistence
now keeps 0 on save and load like NormalizeTimeThresholds, and only a
missing or negative delay takes the default. Pulse saves never wrote a
0, and the first v6 start re-saves alerts.json, so only a newly sent or
hand-edited 0 changes meaning.

On main, a reproducer taking the activation, migration and GET steps
against live evaluation died with "fatal error: concurrent map
iteration and map write" in 5 of 5 plain runs and reported 16 races
under -race. It passes with this change.
2026-10-07 16:57:57 +01:00
rcourtman 22b8b995ca Read the shared wall clock on Alerts overview cards (#2673)
The overview hook kept its own minute signal and handed its value to
each card as "now". A card that mounted between ticks measured from up
to a minute earlier: an alert raised 70 seconds ago read "this minute",
a reading already past the 10-minute stale cut-off led the card as the
live one, a just-raised alert stayed out of Triggered (24h) until the
next tick, and the stale flip and 24h drop landed up to a minute late.

The card now reads useRelativeTimeNow for its started age and the live
reading's stale cut-off, and the hook reads it for the 24h count, so
they measure from the wall clock and move with the shared 30-second
tick. The minute interval stays only for the delivery diagnoses
refresh, a server read that should not double its rate.
2026-10-07 16:56:58 +01:00
rcourtman 2d6a2937fc Keep platform metric alerts through Proxmox node cleanup (#2672)
`CleanupAlertsForNodes` runs on every Proxmox node poll
(`monitor_pve_storage.go`) and on every mock alert tick. It removes
alerts whose `Node` is not a current Proxmox node or PBS instance, and
keeps the types `shouldPreserveAlertOutsideNodeCleanup` lists.

Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or
platform host as `Node`, and a display label (`TrueNAS Disk`,
`Kubernetes Pod`, `vSphere VM`) in `metadata.resourceType`. The preserve
list only had the bare words `truenas`, `kubernetes`, `k8s` and
`vmware`, so none of these labels matched. In an estate with Proxmox
plus one of those platforms:

- every Proxmox poll deleted these alerts without resolving them;
- their reducer incident stayed firing, so the next unified evaluation
re-created each one and handed it to the alert callback again as a new
alert.

## Change

`shouldPreserveAlertOutsideNodeCleanup` keeps an alert that carries
`metadata.platformType` (set on every unified Kubernetes/TrueNAS/vSphere
metric alert), or whose label maps through `CanonicalAlertResourceType`
to one of those platform types. The label path covers alerts persisted
before `platformType` existed. A guest alert whose Proxmox node left the
estate is still removed.

## Evidence

- Unit probe on main: a TrueNAS disk temperature alert, a Kubernetes pod
CPU alert and a vSphere VM CPU alert were all deleted by
`CleanupAlertsForNodes({"pve1": true})`. Over three
cleanup-then-evaluate cycles the TrueNAS alert went to the alert
callback 3 times; with this change, 0 times and it stays active.
- Private mock stack: TrueNAS disk temperature alerts kept vanishing
between mock alert ticks, while the disks kept their readings in
`/api/resources`. With this change they stay open.

## Tests

`TestCleanupAlertsForNodesKeepsPlatformMetricAlerts` (in
`internal/alerts/alerts_test.go`) raises a TrueNAS disk, a TrueNAS pool,
a Kubernetes pod and a vSphere VM alert, then runs the cleanup three
ways: as raised, with only `platformType` (label unrecognised), and with
only the label (`platformType` removed).
`TestCleanupAlertsForNodesStillRemovesMissingProxmoxNodeAlerts` keeps
the removal of a guest alert on a removed Proxmox node. Each preserve
condition was mutation-checked: removing either one fails the first
test.
2026-10-07 16:56:25 +01:00
rcourtman e30b44ea8f Read a silent in-guest agent's guest drawer readings as last known (#2669)
A Pulse agent linked into a VM keeps its last report on the VM resource
after it stops reporting: the registry copies the agent's whole facet,
`agent.stale` included, onto the guest. The guest drawer never read that
flag. A quiet agent's Guest RAID kept its live colours and "Rebuild: 42%
· 118M/sec", its network rows and version read as current, and nothing
said the agent had gone.

## Change

- `useWorkloads` maps `agent.stale` onto the workload as `agentStale`,
and sets `disksFromAgent` when Proxmox reported no guest filesystems and
the agent's own `disks` filled in. Filesystem rows are the only agent
reading with a second source.
- When the agent is stale, the guest drawer:
- passes `HOST_AGENT_STOPPED_REPORTING_REASON` to `RaidCard`
(`lastKnownReason`, #2652);
  - puts a status line over muted Network rows;
  - marks the Pulse Agent row "· stopped reporting";
- reads agent-supplied filesystem rows through the existing `last-known`
disk read state.
- Agent-supplied filesystem rows now follow the agent's freshness
instead of the Proxmox guest-read reason. Before, a VM with a **live**
linked agent but no qemu-guest-agent showed the agent's filesystems as
"Usage unavailable · ?/328 GB" with advice to install qemu-guest-agent.
A stopped guest still reads those rows as unavailable, whether its
status or the Proxmox `vm-stopped` reason says so. Proxmox's own guest
filesystems keep their read state.

## Verification

- Private mock stack built from `main` plus this change. Two host agents
were held past their reporting lease and two left live, then each was
linked into a VM through `POST /api/resources/{vm}/link`. The two silent
VMs serve `agent.stale: true` with the agent's raid, disks and
interfaces; the two live ones carry no stale flag.
- Playwright at 1440 and 390:
  - silent VMs read as described above;
  - the live VM with agent-only filesystems shows usage with bars;
  - the live control is unchanged;
  - no horizontal overflow.
- New tests in `GuestDrawer.test.tsx` cover:
  - silent agent;
  - live agent with Proxmox agent-not-running;
  - Proxmox filesystems under a silent agent;
  - stopped guest, with and without the reason;
  - stale flip in both directions on an open drawer.
The new tests fail against the old drawer. `useWorkloads.test.ts`
asserts source selection with distinct mountpoints.
- Frontend suites over Workloads, hooks, utils, shared, platformPage and
Infrastructure pass, apart from the Mac-only PTY tests in
`agentInstallCommand.test.ts`.
- WorkloadsSurface grows by 0.22 kB gzip (64.85 kB against a ~71.9 kB
cap), and the chunk set is unchanged.

## Not covered

- The workload table's disk cell and the drawer History still read the
aggregate disk metric with the Proxmox reason.
- The agent's OS, its addresses in the identity rows, and the lazily
loaded Physical Disks card's health and wearout carry no stale cue yet.
2026-10-07 16:46:23 +01:00
rcourtman 6d8dce2cae Remove unread Patrol summaryStats and its audit rule
usePatrolIntelligenceState() still returned summaryStats, a closure that
counted critical, warning and active Patrol findings plus fixed outcomes.
Its only reader was PatrolIntelligenceSummary.tsx, deleted in 27ba85ddc6
(2026-06-23). A type-checker scan of the whole program (src and tests)
finds no reference to the member outside the hook's return object, and
nothing spreads, iterates or index-reads the returned state, so the
closure and its return entry go.

The canonical-patrol/no-local-summary-card-presentation rule in
canonical-platform-audit.mjs matched only text shaped like that card's
tint ternaries on summaryStats().criticalFindings, warningFindings and
fixedCount, and told authors to use patrolSummaryPresentation, which no
longer holds any summary-card presentation. With the accessor gone the
rule guards a name nothing can call, so it goes, along with the three
not.toContain guards in frontendResourceTypeBoundaries.test.ts that
pinned the same strings. patrolSummaryPresentation.ts also leaves the
audit allowlist: its remaining recency and verification presenters trip
no rule, and without the entry the audit scans the module like any other
(a planted "Loading messages..." string is now reported).

The JavaScript output changes only by the removed code. Of 187 dist
files, 94 are byte-identical and 92 differ only in chunk-hash references
and SRI strings. In the AIIntelligence chunk, after normalising hashes
and short identifiers, the only residual is the summaryStats closure and
its entry in the returned state. The bundle-size check passes. The Patrol
Inbox, Activity and Protection tabs render without console errors at
desktop and phone widths.

The contracts now record that the hook derives no client-side severity
tally, that the page's open-issue severity counts come from the This week
card's digest read, and that the audit carries no Patrol summary-card
rule. The patrol-intelligence section-owner sentence named a summary
surface that no longer exists; it now names the live tab panels.

Contract-Neutral: Only residual guard demand is the platform support manifest frontend projection proof that the canonical-platform-audit.mjs path policy names (resource.test.ts, sourcePlatformOptions.test.ts, sourcePlatforms.test.ts). This commit deletes a Patrol summary-card rule and one allowlist entry and leaves the projection sync check untouched, so editing those tests would be fabricated proof. patrol-intelligence, api-contracts and frontend-primitives carry substantive notes, and frontendResourceTypeBoundaries.test.ts is the staged patrol page-and-state proof.
2026-10-07 16:35:14 +01:00
rcourtman 421d1e5423 Remove test-only Patrol summary presenters
The Patrol summary card was deleted in 27ba85ddc6 (2026-06-23), but most
of the presenters in utils/patrolSummaryPresentation.ts stayed exported.
A TypeScript import graph rooted at src/index.tsx (static and dynamic
imports, re-exports) shows reachable code imports only two of them:
getPatrolRecencyPresentation for the header recency line and
getPatrolVerificationPresentation for the findings empty state. Apart
from two positive pins removed here, ?raw and readFileSync guards
reference the rest only in negative not.toContain checks, which stay.

getPatrolAssessmentPresentation, getPatrolAssessmentShellPresentation
and getPatrolSummaryMetricState lost their last caller with the card.
getPatrolSummaryPresentation, getPatrolScoreChipLabel and
getPatrolAssessmentAction had lost theirs in May, the
PATROL_NO_ISSUES_LABEL export in March, and getPatrolNoIssuesPresentation
and getPatrolCompactAssessmentLabel never had a production caller. They
go along with their types and the private helpers that only they used,
which a noUnusedLocals check found (repeated until it reported nothing).
The same card deletion left getPatrolConfigurationFailureInlineDetails
in PatrolIntelligenceHeader.tsx formatting an inline save-failure state
the header no longer renders, so it goes too.

88 presenter test cases and 2 header cases go with them. v8 coverage
before and after, matched by function and source text, shows no
statement or branch in the surviving code losing coverage.

The JavaScript output is unchanged. Of 187 production dist files, 93
are byte-identical and 93 differ only in chunk-hash references. The CSS
drops one rule, .bg-emerald-50/60, which only the deleted assessment
shell's class string generated. The bundle-size check passes. The
header recency line and the findings empty state read the same before
and after, at desktop and phone widths.

The contracts described the removed presenters as live.
patrol-intelligence, frontend-primitives and api-contracts now say that
no shared presenter derives an assessment, shell, score chip or next
step today, and they keep those rules as conditions for any returning
surface. They also correct claims that no longer match the code:
- the recency line says "checked N resources" for any completed run
  and never "verified", and labels the latest completed run of any
  type "Last check";
- the findings empty state treats any coverage factor as incomplete
  coverage, even after a clean full run, and the health summary picks
  that empty state's title, not the workspace heading;
- a save that is not ready raises a warning toast, not an inline
  "Patrol control needs attention" state.
2026-10-07 16:17:50 +01:00
rcourtman 58347f0c4f Chart history for disks named by their Unraid serial
A SMART row that reports no serial, smartctl's standby row among them,
takes the serial the host's Unraid inventory reports for the same disk:
the adapter shows it and the disk's metrics target reads it. The SMART
and disk I/O writers keyed the row on its own WWN or host/device key
instead, so the chart read a key nothing wrote and the disk's
temperature and I/O history never appeared. The I/O writer did the same
for an Unraid disk with no SMART reading.

HostSMARTDiskMetricID now takes the serial through the adapter's own
rule (hostSMARTDiskSerial over matchUnraidDisk), and the I/O writer keys
a device without a SMART reading by its Unraid serial before the
Proxmox and host/device fallbacks. Source IDs keep the row's own
identity, so registry keys and rehydration are unchanged.

That rule matches by device path alone, so it now skips a path several
SMART rows share. Controller members behind one block device would
otherwise all take the device's serial and merge into one disk, and the
I/O writer's guard against filing an aggregate counter under a member
would stop working. That guard now compares every row on the path by
its own identity, standby rows and legacy "sdc [sat]" labels included,
so an aggregate counter never lands under one member. The I/O writer
also refuses a device that several Unraid rows name.

Samples already written under the old keys stay where they are. The
chart did not read them while the Unraid row named the disk, and a
device-keyed series can hold another disk's samples once device letters
move, so they are not folded into the serial's history.
2026-10-07 16:16:23 +01:00
rcourtman 9bf72eb407 Merge remote-tracking branch 'origin/main' into fix/global-default-off 2026-10-07 16:08:21 +01:00
rcourtman 564ea41a6e Keep Off alert defaults off after saving
Switching a Global Defaults metric Off on the thresholds page staged -1,
and the save payload passed it through as trigger -1. Alert config
normalization reads a negative default trigger as unset and restores the
factory threshold, so every Off default with a positive factory value came
back On after save and reload: agent CPU, memory, disk and disk
temperature, Proxmox node temperature, PBS, Docker, Kubernetes, TrueNAS,
vSphere and storage. Guest and node CPU, memory and disk escaped only
because nothing normalizes them.

The payload builder now writes every Off default as trigger 0, which the
engine and normalization both read as off. Overrides keep -1, which the
override path stores as written. A -1 already stored on a guest or node
default becomes 0 on the next save.

With -1 and 0 both live Off values, the Global Defaults Custom badge
compares Off values as equal, so the factory -1 I/O defaults no longer
read Custom against a saved 0 and tell users they changed something they
had not. A row's custom marker keeps comparing raw values: it drives the
row's revert control and override removal, so a stored -1 override must
stay visible against an Off default.

Contract-Neutral: Global Defaults Off now persists; the Off wire value and Custom badge rule are recorded in the alerts contract, and frontend-primitives gains no rule
2026-10-07 16:05:38 +01:00
rcourtman 7432995fe8 Stop websocket upgrade headers exempting demo-mode writes (#2666)
`DemoModeMiddleware` let any request carrying `Upgrade: websocket`
through, whatever its method or path. On a `DEMO_MODE` instance a
`POST`/`PUT`/`PATCH`/`DELETE` with that header reached its handler, and
the read-only guard did nothing.

## How exposed the public demo was

- The public demo (`demo.pulserelay.pro`, v6.5.0) runs `DEMO_MODE=true`
with `PULSE_AUTH_USER=demo`, and its login page signs visitors in as
demo/demo. No proxy auth is configured. Caddy is a plain `reverse_proxy`
with no method filtering, and it forwards `Connection: Upgrade` and
`Upgrade: websocket` unchanged.
- That session is the configured admin.
`sessionUserHasSettingsAdminPrivileges` matches `cfg.AuthUser`, so
`RequireAdmin` accepts it, and session requests skip token scope checks.
Nothing in the handlers checks demo mode on its own, so this middleware
was the only write guard.
- Anyone using the public credentials could therefore make any admin
write by adding the two headers. That covers switching the demo out of
mock mode (`POST /api/system/mock-mode`), changing system settings,
creating organisations, importing config, changing the password and
adding notification targets. A CSRF token was needed, but the attacker
gets one from their own login.
- On the old code, the new contract test reproduces this end to end. A
demo/demo session's mock-mode POST with upgrade headers returned 200 and
turned mock mode off.
- I found no sign it was used. Over the last 30 days the demo journal
shows every "mock mode disabled" line paired with a process restart (new
PID). Config file mtimes match deploys. 5,782 ordinary writes were
blocked over the same period. Caddy keeps no access log for the demo, so
this is the absence of evidence, not proof.

## Change

The exemption is deleted rather than narrowed. RFC 6455 handshakes are
`GET` requests, gorilla's `Upgrader` refuses any other method, and `GET`
already passes the read-only branch above it. A narrowed `GET`-only
exemption would never run. `/ws` and `/api/agent/ws` keep connecting,
and the request method alone decides.

## Tests

- `TestContract_DemoModeUpgradeHeadersDoNotExemptWrites` (registered
api-contracts proof) runs against the full router. It signs in as the
configured demo admin and checks that the session passes `RequireAdmin`
for a read. A mock-mode POST carrying `Connection: Upgrade` and
`Upgrade: websocket` now gets the demo 403 and leaves mock mode on, and
a real `/ws` handshake from the same session still upgrades.
- `demo_middleware_test.go`: the table row that asserted the bypass now
expects 403. POST/PUT/PATCH/DELETE with handshake headers to mock-mode,
settings, orgs, `/ws` and `/api/agent/ws` all get 403. Real gorilla
handshakes to `/ws` and `/api/agent/ws` through the middleware still
upgrade and echo.
- Negative control: run against the old middleware via `go test
-overlay`, the new contract test and the upgrade-header test fail, and
the handshake test passes on both versions.

## Not changed here

- `auth.go` `isWebSocketUpgrade` and `configapi`
`explicitAPITokenFromRequest` still accept a `?token=` query credential
on any request with `Upgrade: websocket`. A valid, scoped token is still
required, so no access is gained, but the credential can travel in a
URL. The `ErrorHandler` skip in `middleware.go` only bypasses recovery
and metrics.
- Older and separate:
`/api/ai/sessions/{id}/{abort,summarize,fork,undo,redo}` change state on
`GET`, which the demo guard admits as reads. Tracked separately.
2026-10-07 15:49:33 +01:00
rcourtman cb5ffc2efe Remove dead Patrol assessment and save-failure handoffs
buildPatrolAssessmentAssistantHandoff lost its last caller when the Patrol
summary card's Discuss handler was removed in eac7dfe9ef (2026-05-15), and
buildPatrolConfigurationFailureHandoff lost its last caller in 27ba85ddc6
(2026-06-23). Since then only their own tests called them. A TypeScript
compiler import graph rooted at src/index.tsx confirms no reachable file
imports either one. Removing them also drops PatrolInvestigationContextSummary,
their input and output types, and the private assessment, recent-change,
correlation and save-failure helpers that only they used (found with
noUnusedLocals until it reported nothing).

Rollup had already tree-shaken all of it: production builds of the parent
tree and of this tree are byte-identical across all 187 dist files, and the
bundle-size check passes. The presentation helpers the dead code imported
keep their live callers.

Their test cases go with them. Two cases covered branches the live finding
handoff still uses, so they now run through
buildPatrolAssistantFindingHandoffActions and buildPatrolAssistantFindingHandoff:
a typed pending action without a legacy approval id stays approval-bound,
and the finding context qualifies its resource with the record's node and
truncates long lines. v8 coverage of the surviving model code matches the
pre-removal baseline except one defensive guard the live caller cannot
reach. The raw-source guards pinned none of the removed code.

The Assistant drawer still restores saved patrol_assessment and
patrol_configuration_failure sessions, so that compatibility path stays.

The contracts described both handoffs as live producers, and described a
Patrol summary card, assessment strip, assessment readout, score chip and
header trust line that the page no longer renders. patrol-intelligence,
ai-runtime, api-contracts, frontend-primitives, security-privacy,
storage-recovery, agent-lifecycle and unified-resources now say what the
code does: those handoffs survive only as saved sessions,
the drawer, session list and API keep restoring them, rules for a returning
summary card, strip or readout are conditional, and handoff_actions and
adjacent-boundary clauses name the finding and attention handoffs that send
them today. The run-history handoff clauses also claimed the browser sends a
[Patrol Run Context] block and scoped resources; it sends the briefing and
run identity, and internal/ai/patrol_assistant_handoff.go rebuilds the rest
from the run ID.

docs/AI.md (and its public mirror) told users the legacy operational score
sits in a collapsed supporting-context section. That section and the score
are gone; the section now points at Activity > Finding options and history,
where check history lives. ai_runtime_docs_policy_test.py, its registered
proof, now pins that location and rejects the retired claim. The same file
had failed on main since 2789a15fe5 reworded the Patrol mode API sentence to
"The API retains `autonomy_level` for compatibility" without updating its
pin; the pin now matches that wording.
2026-10-07 15:45:49 +01:00
rcourtman b9c2a90ee7 Raise Proxmox disk temperature alerts under the per-type policy
A Proxmox disk that only the node's sensors report never raised a
temperature alert: CheckDiskHealth judges health and wearout only, and
unified evaluation covers TrueNAS disks, not PVE ones. Physical Disks
could read the disk Running Hot under the per-type thresholds while
alerts stayed silent, and whether a disk alerted depended on whether a
Pulse agent happened to run on the node.

The PVE disk poller now hands each disk's current reading to
CheckProxmoxDiskTemperature, which judges it by the same disk
temperature policy as agent and TrueNAS disks (DiskTempByType under the
agent Disk Temp default) under the disk's existing PVE alert reference.
A disk a still-reporting linked agent lists in its SMART report is the
agent's: CheckHost alerts on it and the PVE alert closes as moved to the
agent, so no disk alerts twice. A retained reading holds the alert,
an excluded device or a switched-off policy closes it, warnings wait the
same 300s stability window as other disk temperatures, a config save
re-judges the alert per disk type, and resource history owns its rows by
the disk's recorded identity like the health and wearout alerts.
2026-10-07 15:42:55 +01:00
rcourtman 75263ea589 Refuse a seeded disk slot mapping when the disk was replaced (#2667)
`ingestRecord` reuses an existing source-key mapping before any identity
matching. For physical disks it checked only the type and parent scope.
Proxmox keys a disk by its slot (`ProxmoxPhysicalDiskSourceID`, e.g.
`pve1-node1--dev-sdb`), so in a registry seeded from persisted unified
resources, a disk swapped into the slot mapped straight back to the
previous disk's canonical resource. Where an agent report had been
merged in, `mergeInto` also kept the old disk's serial, WWN, temperature
and FAILED health. This was found while reviewing #2658, and its
contract note said the seeded mapping was not covered.

**Change.** A Proxmox disk observation now refuses the mapping when the
two disks carry hardware identities naming different disks
(`diskinventory.HardwareIdentityConflict` from #2658):
- Only fields reported on both sides count. A missing serial or WWN,
including Proxmox's literal `unknown`, keeps the mapping.
- Differing serials do not count for SAS, because Proxmox may report the
SAS address there (#1595).
- They also do not count when the mapped disk was merged with an agent
report and Proxmox did not read the drive's own serial. This is the same
rule monitoring's `hostAgentSMARTSerialComparable` applies: NVMe on both
sides, or agent SATA under Proxmox vendor `ATA`, and never a Proxmox
`usb` disk.
- The refused key falls through to the linked-disk join, `findMatch` and
`chooseNewID`, but none of them may merge it back into the refused disk.
An ID landing on it is replaced with the key's source-specific ID.

**Reachability.** No current caller replays Proxmox disk records over a
seeded mapping. The monitor rebuilds a fresh registry each poll. The
resource API, host-continuity and Patrol registries seeded from unified
resources do not replay Proxmox disk records. This closes the registry
contract rather than a reported symptom.

**Proof.** `TestRegistrySeededSlotMappingRefusesReplacedDisk` (in
`internal/monitoring/issue1595_collection_trust_test.go`) builds the
previous disk through `IngestSnapshot` with fixtures shaped like
`monitor_pve`'s output, rehydrates with `IngestResources(List())`, polls
Proxmox, then rehydrates a second time.
- 5 replacement cases: SATA with and without a new WWN, SAS, a
USB-enclosure swap ingested Proxmox-first, and a disk Proxmox alone
reports.
- 8 same-disk controls: re-ingested, no serial, a SAS address serial,
SAS without a WWN, a USB bridge serial, a bridge serial under vendor
`ATA`, a SCSI designator, and a SCSI designator from an agent without
collection status.

Negative controls via `go test -overlay` each fail only their intended
cases: the whole fix removed, the refused-disk exclusion removed, serial
comparability always true or always false, the Proxmox-only branch
removed, a collection-status provenance rule instead, and the USB veto
removed. The combined tree of main, #2660 and this change also passes
`internal/unifiedresources` and the monitoring disk tests.

**Residuals (in the unified-resources contract).**
- Drives that share a serial they do not own, such as a USB bridge's,
still read as one disk, as they do in identity matching.
- The refused disk keeps its Proxmox facet until a rebuild, so the
slot's PVE alert reference resolves as ambiguous rather than to either
disk.
2026-10-07 15:38:50 +01:00
rcourtman 080ace2e97 Show the outdated sensor setup notice on every Proxmox tab (#2668)
Since the per-tab Proxmox queries landed, only the Storage tab loads
`physical_disk` rows. So the outdated sensor setup notice, which judged
nodes from the active tab's disk rows, never showed on Overview.

The registry now derives the verdict on each Proxmox node
(`proxmox.sensorSetupOutdated`, an explicit true or false on every node)
right after child counts resolve each disk's canonical parent. A node is
flagged when its temperature payload is available and in the legacy
`sensors -j` format and a SATA/SAS disk under it has no reading
collected now (`diskinventory.TemperatureCollected`). The page lists
flagged nodes from the rows Overview, Storage, Replication and Backups
already load, so no tab hydrates disks for it.

Rule fixes that came with the move:
- Proxmox's disk inventory types SATA/SAS disks as `hdd`/`ssd`, so the
old `sata`/`sas`-only check never matched a node without a host agent.
- A retained reading no longer counts as current (carried over from
#2659's frontend rule).
- The verdict is sent as explicit false, because the browser's
field-by-field facet merge would otherwise keep an earlier true after
the condition clears.

Stacked on #2659 (shares its `sensorSetup.ts` edit, which this
replaces).

Verified on a private mock stack at 1440px and 390px:
- Overview, Storage, Replication and Backups show the notice for
legacy-format nodes with an `hdd` disk without a reading, a retained
SATA reading, and a SATA disk without a reading.
- Overview makes no `physical_disk` request.
- With `main`'s frontend swapped in, Overview showed nothing and Storage
named one node of three.
- A node whose disk starts reporting drops out on the next websocket
update without a reload.
2026-10-07 15:38:27 +01:00
rcourtman 3e4ea03433 Keep a chart for every host's copy of a same-serial disk
The registry keeps one resource per source ID, and an agent disk's
source ID was its bare serial or WWN. Since same-serial disks on
different machines stay apart (#2660), a dual-ported SAS shelf, cloned
VMs with an explicit serial or fixed-serial USB bridges left every disk
but the last one ingested without its agent source target. A disk only
the agent reports then had no metrics target and no chart, both in the
live registry and in the one the resource API seeds from unified
resources.

The agent SMART and Unraid source ID is now
<agent ID>/physical-disk:<serial or WWN>, and seeding rebuilds it from
the agent host above the disk, directly or through an Unraid array or
cache pool. Seeding an identity-less array disk now reproduces its live
host:device key too, instead of a storage-scoped key no writer uses.

Canonical IDs do not move: SourceSpecificID hashes an agent disk's bare
serial or WWN, so a disk split off onto its source-specific ID, and the
exclusion that split it, keep their recorded IDs. Every host's split
copy shares that ID, and two agent IDs on one machine no longer share a
source key, so findMatch's split branch now takes it keyed to the
machine when another machine's disk holds it, and joins a holder on the
same machine, instead of overwriting either. History stays keyed
by the serial or WWN, which every disk writer and reader shares, so a
drive keeps one history across sources and host moves; a host-scoped
key would orphan the stored history of every agent disk with a serial.
Distinct drives sharing a serial across hosts still interleave in one
series, a residual the contract records.
2026-10-07 15:35:24 +01:00
rcourtman cfa026249f Switch every running tenant monitor when mock mode flips (#2665)
Follow-up to #2656 (the per-monitor mock-mode epoch fence), which
documented this gap as not covered.

## Problem

Mock mode is one process-wide flag (`mock.SetEnabled`), but
`Monitor.SetMockMode` ended the fence epoch, cleared the alerts, forgot
the fixture agents and reset the state of only the monitor it was called
on. The server runs one `Monitor` per organization
(`MultiTenantMonitor.GetMonitor` → `New` + `Start`), and each runs its
own mock alert pass into its own alert manager. A switch made through
one monitor (the mock-mode API uses the requesting tenant's monitor; the
demo-fixture licence sync uses the default one) left every other running
monitor holding what it raised in the mode the process had left: fixture
alerts and fixture-agent node links in live mode, live alerts in mock
mode, until the 24-hour stale sweep.

## Fix

- `internal/monitoring/mock_mode_switch.go`: a process-wide set of
running monitors, guarded by the existing `mockModeSwitchMu`. `Start`
joins it and leaves when its loop returns. While joining it picks the
mock metrics sampler or discovery under the lock (now
`startModeRuntime`), so no switch lands between reading the mode and
starting the runtime for it.
- `SetMockMode` flips the flag once, then for the receiver and every
running monitor: ends the epoch, waits for admitted calls, clears
alerts, forgets fixture agents (when leaving mock mode) and resets state
(`endMockModeEpoch`). Only after that does it restart the sampler, polls
and discovery of each monitor in the set (`resumeAfterMockModeSwitch`).
A monitor that has not joined yet gets its runtime from its own `Start`.
- Each monitor records the mode its alerts and state belong to
(`mockModeAligned`, set in `New` from the same read that decides whether
persisted alerts are restored) and the number of switches made so far
(`mockModeSwitchesSeen`). If it missed switches between `New` and
`Start`, it leaves what it holds before joining, even after a round trip
that brought the flag back. A call for the mode the flag already holds
still moves any reached monitor that belongs to the other mode, and
leaves the rest alone.

Callers are unchanged and no API surface moves.

## Is this reachable in release builds?

Barely. In a `release` build `mockruntime.ValidateEnablement` refuses
mock fixtures unless `SetReleaseFixturesAuthorized(true)` was called.
Its only caller is `syncReleaseDemoFixtureRuntime`, which requires
`DemoMode` plus the `demo_fixtures` entitlement and runs only for the
default organization. Several tenant monitors in mock mode would also
need more than one organization's monitor running on that demo instance.
Demo mode's read-only middleware normally blocks creating organizations
and the mock-mode API's POST. It does forward any request carrying
`Upgrade: websocket` whatever its method; that bypass predates this
change and is being fixed separately. So in practice this is a
dev/mock-stack problem, and the fix stays inside the monitoring package.

## Tests

New tests in `internal/monitoring/monitor_mock_alerts_test.go`:

- `TestLeavingMockModeClearsEveryRunningTenantMonitor` and
`TestEnteringMockModeClearsEveryRunningTenantMonitor` start the default
and one other tenant monitor through `MultiTenantMonitor`, as the server
does, and wait until both have joined. They raise alerts in both, switch
through one, and check both.
- `TestMonitorStartedAfterAMockModeSwitchLeavesTheModeItWasBuiltIn`
covers a switch that lands between `New` and `Start`.
- `TestMonitorStartedAfterAMockModeRoundTripDropsWhatItRaisedMeanwhile`
covers two switches in that window, with fixture alerts raised in
between.
- `TestSetMockModeAlignsAMonitorWhoseModeTheFlagAlreadyHolds` covers a
call for the mode the flag already holds.
- `TestMockModeSwitchLeavesAnUnjoinedMonitorsRuntimeToItsStart` covers a
switch reaching a monitor that has not joined.

One more is in
`internal/monitoring/monitor_host_agent_removal_lifecycle_test.go`:
`TestLeavingMockModeReleasesFixtureAgentNodeLinksOnEveryRunningMonitor`.
It switches through the default monitor and checks that the other
tenant's fixture node is no longer suppressed by a fixture agent's
leftover node link.

Negative controls (`go test -overlay`):

| Control | Failing tests | What happened |
|---|---|---|
| Fence-PR runtime | Leaving, entering, node-link | Default monitor kept
69–104 fixture alerts (varies by run) and 69 fixture agents; org-b kept
its live alert; org-b's `mock-cluster-1-pve1` stayed suppressed |
| Set kept, switch reaches only the receiver | Same three | Same
failures |
| No realign at join |
`TestMonitorStartedAfterAMockModeSwitchLeavesTheModeItWasBuiltIn` | |
| Join compares only the mode, not the switch count |
`TestMonitorStartedAfterAMockModeRoundTripDropsWhatItRaisedMeanwhile` |
Started in live mode with the fixture node's memory alert |
| Plain early return when the flag already matches |
`TestSetMockModeAlignsAMonitorWhoseModeTheFlagAlreadyHolds` | |
| Resume ignores set membership |
`TestMockModeSwitchLeavesAnUnjoinedMonitorsRuntimeToItsStart` | |

Every control failed only the tests it targets.

Also run: the full `internal/monitoring` package; `-race -count=3` on
the new tests plus the existing `TestLeavingMockMode*` and
`TestMockModeFence*`; and the mock-mode tests in `internal/api` and
`internal/api/configapi`.
2026-10-07 15:27:59 +01:00
rcourtman 7a026891c6 Keep a low alert default's clear below its trigger
Setting a global default to a positive trigger of 5 or less (Machines CPU
at 1%) stored agentDefaults.cpu as {trigger: 1, clear: 75}. The thresholds
page sends clear max(0, trigger - 5), which is 0 there. NormalizeAgentDefaults
then derived trigger - 5, found it non-positive and fell back to the
factory clear, and ValidateHysteresisThresholds never checked agent
CPU/memory/disk. PBS, node temperature and the persistence normalizer had
the same fallback (validation repaired PBS and node temperature on save).
Per-type disk fill and disk temperature entries were worse: a clear of 0
reset the whole pair, so a SATA disk temperature trigger of 4 was stored
as 55.

Alert firing and resolving did not change for these pairs:
buildCanonicalMetricSpec drops a clear at or above the trigger and the
reducer then clears at the trigger. The wrong level showed in
/api/alerts/config and alerts.json, and firing agent alerts reported it as
clearThreshold (75 on a 1% CPU alert, 85 on a disk alert still firing at
81.8%).

PBS, node temperature, agent and per-type disk defaults now use
normalizeThresholdPointer, which Kubernetes, TrueNAS and vSphere already
use: a missing clear sits five points under the trigger floored at 0, and
a clear at or above the trigger is repaired, so an API-written {50, 80}
now gets a real recovery band at 45. A non-positive per-type trigger still
resets to the type default. Persistence calls the same normalizer through
a new NormalizeHysteresisThreshold instead of its own copies, so configs
saved before this load repaired.
2026-10-07 15:26:46 +01:00
rcourtman 900d620abd Rank a confirmed availability outage first in the summary check (#2664)
A machine with several availability checks shows one summary check, and
its health reads only that summary. The summary ranked every observed
failure equally and broke ties by target id. So a check that had failed
once, below its threshold, could hide a later-sorting check with a
confirmed outage. The outage check's own row carried
`availability_unreachable` while the machine it monitors read healthy.

- `availabilityCheckPriority` now ranks a confirmed outage first, then
any other observed failure, then unchecked/ambiguous/unresolved, then
passing. Ties still go to the first target id.
- `availabilityOutageConfirmed` is the one confirmation rule, shared by
the ranking and `EvaluateResourceHealth`. It mirrors the poller's
incident gate: observed failures at the threshold, an unavailable
aggregate when one is reported, and no indeterminate outcome. A probe
agent that stops reporting keeps its old failure count but confirms
nothing, on its own row or on the target.
- Tests in `availability_link_test.go` go through the registry
projection path: a confirmed outage behind an earlier unconfirmed
failure (and its recovery), and a silent probe agent's stale failure
count. Both fail without the change. `health_test.go`'s legacy fixture
gains the `LastChecked` every producer sets.
- The `unified-resources.md` contract describes the summary ranking and
the shared rule.

Drawers already list every check from `availabilityChecks`, and
availability incidents live on each check's own row, so alert synthesis
is unchanged.
2026-10-07 15:23:46 +01:00
rcourtman 0f6d1e4180 Stop a failing availability check keeping a node online (#2663)
Follows #2643, which made the stale pass read each source's own verdict
but left availability checks counting by delivery.

## Problem
Once every source with a verdict had gone quiet, any current
availability sighting kept its target online. A node that
`preserveOrExpireNodes` expired stayed online while its HTTP check
failed, and a quiet failing check lifted that node's offline verdict to
warning. One sighting stands for every check on a target and carries
whichever check was projected last, so a failing check that had just run
also vouched for another check's old pass.

## Change
- **Registry verdict** (`aggregateStatus`,
`availabilityChecksProveOnline`): each check on a monitored resource is
judged by its own evidence window (`Evidence.ValidUntil`). An enabled,
passing check with current evidence proves the resource answers and
reads online once one of the resource's own sources has gone quiet and
none that is current has a verdict. Every other check abstains, current
or quiet. A failing probe never makes a guest count as stopped
(workloads summary, Proxmox page counts). A check's own row keeps the
ordinary rules.
- **Ownership**: only a target's own quiet sources hand the stale pass
its status. Projecting, re-running or retargeting a check re-applies the
stale pass's rule to targets it owns, because the resources API replays
checks after its stale pass. A manual link judges the merged resource by
the checks it carries.
- **Poller evidence** (`internal/monitoring`):
- A local location beside other locations ages out like a silent remote
path ("no recent local check").
- An available check's evidence lasts only as long as its newest
reachable path.
- After an entitlement lapse, an adopted remote result keeps the earlier
of its agent check time and the server receipt time.
- A multi-location aggregate is never adopted as a local observation;
the target reads as never checked until the local poller runs.

Contracts: `unified-resources.md` ("Status follows each source's own
verdict") replaces the residual paragraph, and `monitoring.md` covers
the multi-location rules.

## Verification
- New tests in `registry_test.go`, `registry_merge_policy_test.go` and
`availability_probe_agent_test.go`. Each fails against the base runtime
via `go test -overlay`, including both projection orders.
- Full `internal/unifiedresources` and `internal/monitoring` suites
pass, plus the availability, connection, workload and resource-state
tests in `internal/api`, `resourceapi` and `chartapi`.
2026-10-07 15:23:03 +01:00
rcourtman 6d8e2c9e28 Stop Patrol fetching the correlation list nobody reads
The Patrol page fetched GET /api/ai/intelligence/correlations on every
mount, every 60 s poll, every return to the tab and on Retry, and the
store's dashboard bundle fetched it again for FindingsPanel's refresh.
Nothing has read that list since the Patrol supporting-context block was
removed in 27ba85ddc6; the resource drawer reads per-resource
correlations from the resource-intelligence payload instead.

Remove the store's correlation signal, getter and loader, drop the read
from the Patrol background refresh and the dashboard bundle, and remove
AIAPI.getCorrelations, which had no other caller. The backend route
stays as an API surface. Dead route stubs go from six E2E specs, and
contract prose that said the Patrol page loads or renders correlations
or recent changes is corrected.

Measured on a demo-mode stack: correlation requests fall from one to
zero per Patrol load, reload, 60 s poll and tab return at 1440x900 and
390x844, with every other AI read unchanged. Production bundle JS gzip
drops 0.18 kB with no chunk renamed.
2026-10-07 15:17:11 +01:00
rcourtman 8e1a564d7e Judge TrueNAS disk temperature alerts by disk type
Every disk heat judge follows the per-type disk temperature policy
(NVMe 70C, SAS 65C, SATA 55C, under the agent Disk Temp default) except
TrueNAS disk temperature alerts, which used a flat 55/50 TrueNAS Disks
default. A TrueNAS NVMe at 60C raised a warning while the TrueNAS storage
table, Physical Disks and Patrol called it fine, the false NVMe alert
#1388 removed for agent disks.

TrueNAS disks now resolve their trigger per disk type unless the user
saved a TrueNAS-wide value, which still applies to every TrueNAS disk; a
per-disk override beats both. Saved configs keep a chosen value: a stored
55/50 is dropped only from configs written before the new
truenasDiskTemperatureByType marker, because the thresholds page wrote
that factory value back on every save. Alerts record their disk type so
a config save re-judges them per type, and a retained reading no longer
raises one.

The TrueNAS Disks Global Defaults cell reads "By type" while unset (Off
when the agent default switches the policy off), each disk row shows its
type's trigger, and Off is written as trigger 0. The shared alert table
now reads its props reactively, so the Custom badge and reset control
follow a config that loads after the table mounts. The TrueNAS disk
drawer tones a current reading by the same per-type trigger instead of
a fixed 55C.
2026-10-07 15:11:44 +01:00
rcourtman d67bef7519 Merge remote-tracking branch 'origin/main' into refactor/drop-patrol-context-summary 2026-10-07 15:09:09 +01:00
rcourtman 6a5b7d04c0 Remove test-only Patrol context summary helpers
buildPatrolInvestigationContextSummary and selectPatrolSupportingRecentChanges
lost their last production caller when the Patrol page's supporting-context
block, with its Details toggle for recent changes, learned correlations and
policy coverage, was removed in 27ba85ddc6. Since then only their own tests
and a raw-source guard referenced them. A TypeScript-compiler import graph
rooted at src/index.tsx confirms nothing reachable imports either export.
Rollup had already tree-shaken them: before/after production builds are
byte-identical across all 187 dist files.

The raw-source guard in frontendResourceTypeBoundaries.test.ts pinned the
removed builder. It now pins the export of the finding handoff that the
Patrol hook imports, and checks the hook's import declaration rather than the
symbol and module path as separate strings. The 'policy-covered resource'
copy pin becomes a negative pin, so the summary cannot quietly return.

The contracts still described the removed block as a live surface: a
compact Details affordance and inspector, a summary card that keeps recent
changes and correlations primary, the Patrol page rendering the shared
correlation and change cards, and the summary derivation in the model. The
patrol-intelligence, frontend-primitives, api-contracts and ai-runtime
clauses now say what the code does. Patrol renders none of that evidence and
reads the AI summary only for overall health. The cards have no resource-link
default since the drilldown links were retired. The rules for a returning
summary or recent-change normalization are conditional. The Patrol page
rule that still allowed a Details affordance also contradicted the existing
prohibition on a supporting-context panel a few clauses earlier.

The Patrol assessment and save-failure Assistant handoff builders are also
unreachable and are being removed separately with their prose, as is the
Patrol refresh path's unread correlation-list fetch.
2026-10-07 15:04:51 +01:00
rcourtman 732bb6856a Merge remote-tracking branch 'origin/main' into refactor/drop-summary-export-orphans 2026-10-07 15:04:07 +01:00
rcourtman 8676ec687f Read the shared wall clock on Alerts overview cards
The overview hook kept its own minute signal and handed its value to
each card as "now". A card that mounted between ticks measured from up
to a minute earlier: an alert raised 70 seconds ago read "this minute",
a reading already past the 10-minute stale cut-off led the card as the
live one, a just-raised alert stayed out of Triggered (24h) until the
next tick, and the stale flip and 24h drop landed up to a minute late.

The card now reads useRelativeTimeNow for its started age and the live
reading's stale cut-off, and the hook reads it for the 24h count, so
they measure from the wall clock and move with the shared 30-second
tick. The minute interval stays only for the delivery diagnoses
refresh, a server read that should not double its rate.
2026-10-07 15:03:02 +01:00
rcourtman bdf0f76b7b Show retained disk temperatures as last known in drawers and tables (#2659)
Normalization keeps a disk's last temperature when the current
observation did not collect one, such as a disk in standby or a host
agent past its reporting lease. #2603 taught the Storage presenters to
show that value as last known. Four surfaces still read the raw number
as a current reading. Each now goes through the canonical
`getPhysicalDiskTemperaturePresentation` /
`isPhysicalDiskTemperatureCurrent` instead of adding a predicate.

- **TrueNAS disk drawer** (`resourceDetailDrawerTrueNASModel.ts`): the
Temperature row took the 55°C warning tone from a retained reading, and
the summary listed it as a reading. It now reads "61°C (last known)"
with the reason as title and no tone, and the summary leaves it out. A
current reading keeps the existing tone. Replacing the fixed 55 with the
alert policy is a separate follow-up.
- **Drawer History fallback**
(`resourceDetailDrawerMetricsHistoryModel.ts`): with no stored points, a
retained reading was labelled "current". It now goes through the
deferred-metric slot, so the legend says "last known" and the reason
shows above the chart. A disk's value is read from `physicalDisk` beside
its collection state. Websocket rows carry no top-level `temperature`
for a disk, so on the TrueNAS page the fallback had shown "-" even for a
current disk.
- **TrueNAS storage table** (`TrueNASStorageTopologyTable.tsx`): the
Temp cell showed a retained reading plainly, and hottest-first sorted it
to the top of its group. It now renders muted with its reason, and the
sort ranks it as no reading.
- **Proxmox outdated sensor setup notice** (`sensorSetup.ts`): a
retained reading counted as a temperature arriving, so the notice could
hide for a SATA disk Pulse cannot read now.

Verification: a regression test per surface fails on main and passes
here. A private mock stack with a local-only overlay stamped one TrueNAS
disk and one pve3 SATA disk with an unavailable temperature state ("host
agent stopped reporting") and marked pve3's sensor payload legacy.
Playwright at 1440×900 and 390×844 compared the change against the same
session with main's runtime files. The receipt is
`frontend-modern/browser-verification/294409f678589ef2.json`.
2026-10-07 14:58:32 +01:00
rcourtman 7a8376c055 Keep platform metric alerts through Proxmox node cleanup
CleanupAlertsForNodes runs on every Proxmox node poll 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 as
their node and a display label such as "TrueNAS Disk" in
metadata.resourceType, which the preserve list's platform words never
matched. So in an estate with Proxmox and one of those platforms, every
poll deleted these alerts silently while their reducer incident stayed
firing, and the next evaluation re-created each one and handed it to the
alert callback again as a new alert.

Keep alerts that carry metadata.platformType, or whose label maps to a
Kubernetes, TrueNAS or vSphere type. A guest alert on a removed Proxmox
node is still removed.
2026-10-07 14:54:59 +01:00
rcourtman bab38a25b9 Drop the summary chart caches nothing reads (#2662)
The Workloads table history hook was the last caller of
`infrastructureSummaryCache.ts` and `workloadsSummaryCache.ts`, and
nothing has read either cache since the InfrastructureSummary card and
the standalone Workloads summary were deleted on 2026-05-26. The hook
runs both fetchers inside `createNonSuspendingQuery`, which always
passes an abort signal, so their in-flight dedupe was skipped too. Each
30-second Trends poll still serialized the full response on the main
thread and wrote it to localStorage or, above the 900k-character cap,
deleted the key.

Measured on embedded builds against the 50-node, 929-guest mock estate
at the 1h range:

| | Before | After |
|---|---|---|
| JSON serialized per poll | 9,629,902 + 924,249 chars (5-31 ms each) |
none over 200k |
| Summary-cache storage calls | `removeItem` on both keys every poll |
none |
| `/api/charts/workloads` + `/api/charts/infrastructure` | one each on
load and per poll | same |
| Rows drawing history (1440 / 390 wide) | 12 nodes + 140 guests / 6 +
36 | same |

The hook now calls `ChartsAPI.getWorkloadCharts` and
`ChartsAPI.getInfrastructureSummaryCharts` directly with the same range,
signal, `maxPoints`, node scope and metric list. The node, agent and
Docker-host series fold moves into `workloadMetricHistoryModel.ts` with
its merge rule unchanged. The query layer already retains reads across
remounts for five minutes and clears them on org switch. The hook
replaces the deleted cache under the `workloads-hot-path` policy.

The unified-resources, performance-and-scalability and
frontend-primitives contracts still described InfrastructureSummary, its
model and state hook, the shared summary table, selector and details
drawer, WorkloadsSummary, throughputPresentation, resourceBadges and the
two caches as live owners. Each clause now names the live owner, becomes
a conditional rule, or is removed where nothing it governs remains.
Stale prose for the other summary primitives deleted in May
(SummaryMetricCard, InteractiveSparkline, DensityMap, StorageSummary)
and their test-only helpers is left to a separate change.

Checks: affected vitest suites (10,053 tests; the only failures are the
Mac-only `agentInstallCommand` suite), contract, registry and status
audits, and the browser verification receipt.
2026-10-07 14:52:53 +01:00
rcourtman 156d2e274d Keep same-serial disks on different machines apart (#2660)
## Problem

`ingestRecord` refuses a physical-disk match under another parent
(`findMatch` skips scope-incompatible candidates), then `chooseNewID`
mints the same hardware-keyed canonical ID (`machine:<serial>`) and the
ingest merges into whatever disk already holds it. Disks that report one
usable serial on several machines collapse into a single resource
parented to whichever machine ingested last, and every other machine
loses the disk from inventory. Real triggers: dual-ported SAS shelves
seen by two nodes, cloned VMs with an explicit identical serial,
fixed-serial USB bridges, TrueNAS systems. Repro: `IngestSnapshot` with
three PVE nodes in one instance, each with a `/dev/sda` of the same
serial, gave one disk.

## Fix

- A new disk resolves its parent to a machine: the nearest host
ancestor, so a TrueNAS pool or Unraid array counts as its system or
host.
- Two disks with one identity stay apart only on evidence: one reporter
placing the serial on two machines (cloned VMs keep both serial and
hostname), or machines whose hostnames share nothing. Reporters on one
box still join across grouping parents and unmerged host resources, so
the TrueNAS API and the agent on that box stay one disk. An unknown
machine never splits a disk, so a PVE row whose node row is missing
still joins its host's agent disk.
- Once a serial spans machines, every copy takes the serial keyed to its
own machine, and the holder of the unscoped ID is re-keyed with its
source mappings, retired-ID claims and relationship endpoints. IDs don't
depend on machine ingest order, and the unscoped ID never passes between
disks that coexist. A disk only one machine reports keeps its current
ID.
- Operator splits ("Split merged resource" exclusions) now hold on this
path; the old collision merge ignored them. A split recorded against any
ID the disk can hold applies, so it survives a re-key.
- The registry indexes disks by hardware key (`physicalDisksByHardware`)
because `IdentityMatcher` keeps one resource per machine ID.

## ID stability decision

Machine-scoped IDs only for identities that actually span machines,
rather than a pre-pass. A pre-pass needs final parent scopes before
ingest, but host/node merging happens during ingest. Giving the
first-ingested machine the unscoped ID would hand one machine's ID, and
its operator state, to another machine when an earlier-sorting machine
appears. With the chosen design a disk's ID changes when a same-serial
disk first appears on another machine. That orphans ID-keyed state
rather than misapplying it.

## Residuals (documented in unified-resources.md)

- Like any serial-keyed ID, the unscoped ID still passes to a
same-serial disk that appears once the previous holder is gone.
- Alert-history rows owned under the unscoped ID don't follow a re-key.
The PVE disk alert owner (#2646) already leaves shared-serial rows
unbound unless a WWN tells them apart.
- Agent SMART and Unraid disk source IDs are the bare serial.
Same-serial agent disks on different hosts share one `SourceAgent`
mapping, and only the last ingested keeps the agent source target. This
is tracked as a separate task, because the fix re-keys persisted
mappings.
- Same-serial disks share one metrics history key.
- Across reporters, hostnames that normalize alike still join.

## Verification

- New tests in `registry_merge_policy_test.go`: per-machine disks for
PVE-only and linked-agent clusters, including reversed ingest order;
rehydration with a machine joining; agent + TrueNAS on one box versus a
cloned TrueNAS with the same hostname; a disk moving into a pool; a
parentless PVE row on a shared serial; exclusions honoured, surviving a
re-key, with per-machine fallback; serialized-JSON rehydration;
retired-ID claims carried across a re-key.
- Every new test fails against the unfixed registry or with its fix
removed (`-overlay` negative controls).
- `go test ./internal/unifiedresources` passes, as do the
disk/SMART/Unraid/TrueNAS tests in `internal/monitoring`, `internal/api`
and `internal/truenas`.
- Five rounds of independent review; the last round found no P1/P2
issues.
2026-10-07 14:50:15 +01:00
rcourtman 61c639223d Remove exports orphaned by the summary component deletions
The May cleanup deleted InfrastructureSummary, StorageSummary, the shared
summary table and the SummaryMetricCard, InteractiveSparkline and
DensityMap primitives, but left the helpers only they called. Each one
below has had no production caller since then and survived only through
its own tests and raw-source guard pins:

- getMetricsChartKeyCandidatesFromResource (agentResources.ts)
- getNormalizedIdentityLookupVariants and getAgentLikeIdentityAliases
  (resourceIdentity.ts); the variants helper was briefly reused for PBS
  history and dropped again on 2026-09-30
- getPBSStatusIndicator (status.ts)
- useSummaryContextualFocusState (contextualFocus.ts), which takes
  filterSummarySeriesByGroupScope, resolveSummaryCardInteractionState and
  SummaryCardInteractionState with it
- the deprecated ChartsAPI.getInfrastructureCharts delegate; its branch
  tests now exercise getInfrastructureSummaryCharts directly

A production build differs from its parent only by that delegate's static
method; every other removal was already tree-shaken.

The frontend-primitives, storage-recovery and performance contracts still
described the deleted summary cards, sparkline, density map and summary
panel as live owners, and credited contextualFocus.ts with the removed
hook's series filtering. Those clauses now name the live owners
(summaryCardInteraction.ts behind summaryTableFocus.ts, the storage
summary cache behind the pool Growth column, TooltipPortal for chart
tooltips), become conditional on a summary strip returning, or go.
2026-10-07 14:49:28 +01:00