Commit Graph

13111 Commits

Author SHA1 Message Date
rcourtman 533027ee06 Drop Patrol state members no surface reads (#2695)
`usePatrolIntelligenceState()` returned 18 members that no file outside
the hook reads. A TypeScript checker scan of src and tests found no
readers and no spread, computed or `Object.*` escapes of the state
object. Several were also dead inside the hook.

## Change

- **Return object.** Drops activePatrolFindings, activityRefreshTrigger,
acceptedManualRunId, assistantHandoffFindingId, circuitBreakerStatus,
fullModeUnlocked, initialSurfaceReady, isRefreshing, loadAllData,
openAssistantOperationsLoopForFinding, the four decision/outcome counts,
both work-evidence counts, recordPatrolWorkflowStarterActivity and
setFullModeUnlocked, plus handleAssistantFindingHandoff (see below).
- **Dead locals.** Removes the write-only signals (initialSurfaceReady,
isRefreshing, fullModeUnlocked, assistantHandoffFindingId), the 15 s
background refresh timeout that only reset isRefreshing, the unread
memos, and the never-called `openAssistantOperationsLoopForFinding`.
That last one was a second copy of the handoff `FindingsPanel` already
performs. Locals still used inside the hook (activePatrolFindings,
loadAllData, activityRefreshTrigger, acceptedManualRunId) stay.
- **Test-only exports.** `openPatrolAssistantWorkflowHandoff` and
`recordPatrolWorkflowStarterActivity` were used only by that dead path
and by their own tests, so they go.
- **Workspace.** `handleAssistantFindingHandoff` only wrote the unread
handoff signal, so `PatrolIntelligenceWorkspace` no longer passes
`FindingsPanel` an `onAssistantHandoff` callback. The prop is optional.
- **Tests.** Raw-source pins on removed code are dropped. The
manual-sync timeout test now pins the Retry path's own generation guard;
a mutation of its timeout fails it. The non-blocking starter test now
exercises `recordPatrolControlStarterActivity`.
- **Contracts.**
- patrol-intelligence said a finding-to-Assistant handoff *must* record
a `pulse_patrol` workflow starter. Only the dead path did that; the live
`FindingsPanel` handoff never has, so `pulse_patrol` has no browser
producer. The clause now says that and keeps the content-free rules as a
condition.
- The "local Patrol state must expose work evidence" sentence no longer
requires counts nothing reads.
- Three refresh clauses that described a header refresh action now name
the stale-data Retry, the only operator refresh left.
- api-contracts and frontend-primitives record that `FindingsPanel` owns
the handoff.

## Not in this PR

The store still fetches `GET /api/ai/circuit/status` for nothing. That
removal is a follow-up after #2677, because it edits the same lines.

## Evidence

- Production builds before and after compared with chunk hashes, SRI
strings and short identifiers normalised. Only the removed hook code
differs, the unused `pulse_patrol` constant drops out of `aiChat.js`,
and `index.js` reorders the same preload list. `check-bundle-size.mjs`
passes.
- Private demo-mode stacks for both builds, driven by the same
Playwright script at 1440x900 and 390x844: Patrol load, Activity >
Finding options and history, Open in Assistant (drawer opens with the
finding), tab hide/show, and the forced stale-data banner plus Retry.
Each phase hit the same `/api` endpoints on both builds, and neither
build had console errors.
- `tsc --noEmit`, eslint, and vitest for `src/features/patrol`,
`AIIntelligence.test.tsx`, `frontendResourceTypeBoundaries.test.ts` and
`FindingsPanel.test.ts` pass, as does the full pre-commit battery.

Stacked on #2687 (which carries #2682 and #2686). Until those merge,
this PR shows their commits too. `git merge-tree` is clean against main,
#2690, #2677 and #2691.
2026-10-08 03:01:08 +01:00
rcourtman 91dbc94b5f Colour pulse-relaxed guests by the thresholds their alerts use (#2735)
The `pulse-relaxed` Proxmox tag lifts a guest's CPU, memory and disk
alert triggers to a 95/92/95 floor (applied in
`effectiveAlertPolicyNoLock` since #2705), but the browser coloured bars
from the configured thresholds alone. A relaxed guest at 88% memory
showed a red bar while the alert engine stayed silent until 92%, and the
Alerts thresholds row kept showing 80/85/90 with no sign the tag raised
them. That is the gap a user described on #863 (comment of 2026-01-16):
the tags override the UI settings "but there's no visible indication in
the UI".

## What changes

- **Bars follow the tag.** `resolveMetricDisplayThresholds` takes the
tags the engine reads for a guest and applies the backend's
`applyRelaxedGuestThresholds` step before mapping to display thresholds:
unset gets the floor, a lower trigger is raised and keeps its clear
point (a missing clear is filled five below, as
`ensureHysteresisThreshold` does) unless the raised trigger reaches it,
and Off stays Off. A relaxed guest now turns red where its alert fires.
- **Only Proxmox guests.** `getWorkloadAlertPolicyTags` and
`useThresholdsGuestData` pass tags only for Proxmox VMs and LXCs, the
only guests whose tags reach alert policy. vSphere and TrueNAS VMs (also
`type: vm`) and containers keep their thresholds whatever they are
tagged.
- **The thresholds row says what is in effect.** A relaxed guest's
Alerts → Thresholds row (table and card) adds one line, "Proxmox tag
pulse-relaxed: alerts at CPU 95% · Memory 92% · Disk 95%", listing only
the thresholds the tag actually raises. It follows unsaved edits. The
cells keep the configured values the editor changes.

## Why the row and not the drawer or table

Following the user-lens procedure: the job is "I tagged this noisy VM
pulse-relaxed: is Pulse honouring it, and when will it alert?". Before
this change the bar answered it wrongly at zero clicks, and the only
surface that states thresholds (Alerts → Thresholds → Proxmox → VMs &
Containers, then search) stated the wrong numbers. No user doc describes
the tag, and GitHub comments give conflicting values (95/95/98 in #323,
95/92/95 in #863), so the row has to state the numbers. A badge in the
guest drawer or table row would fail the actionability test: once the
bar colour is right, the user has nothing to do there, and the place to
act is the thresholds row (or the tag in Proxmox).

## Verification

- Private mock stack from this branch with a dev-only overlay tagging
two demo profiles `pulse-relaxed` (68 VMs). Before the change: relaxed
`ci-runner-117` at 88% memory was red. After: every running relaxed row
matches the relaxed band (`analytics-batch-70` at 87% reads warning),
while untagged guests at 86–92% stay red, at desktop and 375px. The
thresholds row line was checked in the card layout, in the table layout
(full-width mode at 2560px), at 375px, and through an unsaved edit
(Memory 96 drops it from the line). Receipt:
`frontend-modern/browser-verification/1f9dcc1c17da6345.json`.
- Proof files: `metricThresholds.test.ts` (relaxed resolution, Off,
unset, clear filling at margins 5 and 10, scope gating),
`workloadTopology.test.ts` (Proxmox-only tags, legacy tag strings),
`ResourceTable.test.tsx` (desktop and mobile line, omitted metrics,
edits, disabled and untagged rows). `GuestRow.test.tsx` gets the new
export in its topology mock.
- On pulse-dev: tsc, `npm run lint` and the Alerts, Workloads, utils,
stores, platformPage, shared and `src/__tests__` suites. The broad run's
only failures were missing repo-root files from a partial sync and 5s
timeouts at load ~65; all 15 affected files pass when rerun with the
full tree.

## Known limits (existing, not widened here)

- The display resolver does not read filter-driven custom rules, which
the engine folds in before the override, so a guest a custom rule
matches can colour by thresholds the engine does not use. Custom rules
have no UI today. The alerts contract now says so.
- The Alerts page's guest list memo compares only id and name, so adding
or removing the tag in Proxmox shows on that page after a reload.
Workloads rows update live.
- The thresholds page reads an unset global default as Off
(`getTriggerValue`), so the line omits the floor the engine would give a
genuinely unset default. A save from the Alerts page always writes these
defaults.
- `pulse-no-alerts` and `pulse-monitor-only` still have no indication on
the row. They are tracked with the rest of the #863 comment in the
demand ledger.
2026-10-08 02:59:25 +01:00
rcourtman 1c6d2a0b2f Stop Storage scrolling back to open pools and reopening groups (#2733)
Follow-up to #2709.

## Problem
On Proxmox > Storage (pools view):
1. With a pool open, scrolling the page back to the top pulled it back
down to the open pool a second or two later, and kept doing so.
2. With `?group=node`, collapsing the open pool's group (Collapse button
or header click) bounced it straight back open.

## Causes and fixes
- **Bridge reveal re-ran on unrelated state.**
`useSummaryTableFocusBridge` called the consumer's `revealActiveSeries`
inside a tracked effect, so Storage's reads (the pool-to-group lookup
rebuilt on every live update, the expanded group set) re-ran the reveal
and its inline-detail scroll. The effect now depends only on a memo of
the focused series id and the table root, and runs the callback and
scroll untracked (`summaryTableFocus.ts`). Cleanup is registered before
the first attempt, so a focus change or unmount cancels pending settle
frames.
- **Expansion state reopened collapsed groups.**
`syncExpandedStorageGroups` re-added every group key missing from the
expanded set whenever live data rebuilt the groups, so any collapsed
group reopened within ~10 s. The bounce above had been hiding this.
`useStorageExpansionState` now stores only the collapsed groups and
derives the expanded set, so new groups still open by default. Group
keys are bare labels, so a grouping change clears the collapsed set.
That stops a label carrying collapse state into another grouping or
hiding the headerless `All` group.

## Evidence
12-node mock stack, Chromium at 1440x900 and 390x844, same stack swapped
between #2709's code and this change:

| Check | Before | After |
|---|---|---|
| App-shell scrollTop after scrolling away from an open pool (every 0.5
s, 12 s) | 0, 0, 1008, then 1111 ×21 | 0 ×24 |
| Collapse open pool's group via button, group state (0.5 s samples) |
open ×12 | shut ×32 (16 s) |
| Same via header click | open ×12 | shut ×32 (16 s) |
| Disks view / Workloads table scroll-away | 0 ×24 | 0 ×24 |

Unchanged: lower pool, disk and guest rows open their detail with the
row header and detail top in view; Escape closes it;
`?resource=ceph-rbd` opens in view; collapsing `shared` and then
following an in-app `?group=node&resource=ceph-rbd` link reopens the
group and shows the detail; a `?summaryGroup=` deep link scrolls to the
pinned header; regrouping node → type → none → node shows every pool.

Tests: new bridge tests (`summaryTableFocus.test.tsx`), Storage-level
live-update and collapse tests (`Storage.test.tsx`), expansion hook
tests, and a guardrail pin that the reveal runs untracked. All six new
tests fail on #2709's code. The three expansion tests also fail with the
new bridge but the old expansion state.

## Known limit
Filter changes do not re-run the reveal. A focused pool whose row stays
filtered out past the reveal's ~1.2 s retry window is not revealed when
the filter clears. Storage filters live in the URL and a storage link
without filter parameters resets them, so only a link carrying a filter
that excludes its own pool reaches this. Likewise, with a pool already
open and its group collapsed, an in-app link to that same pool leaves
the group collapsed, because focus does not change.
2026-10-08 02:58:15 +01:00
rcourtman 295edef4d5 Bind SAML logins to the browser that started them (#2729)
## Why

With `allowIdpInitiated` off, the default, every SAML login failed.
`SAMLService.ProcessResponse` passed crewjam/saml an empty list of
request IDs, and crewjam refuses any Response whose `InResponseTo` is
not in that list, so both SP-initiated and unsolicited Responses ended
in "Authentication failed". Only providers with IdP-initiated logins
enabled, which skips the check, could sign in.

## What changes

- `/api/saml/{id}/login` records each AuthnRequest ID, on the provider's
service, with the login's `returnTo` and the hash of a random token it
sets as the `HttpOnly`, `SameSite=Lax` cookie `pulse_saml_login`
(`__Host-pulse_saml_login` over HTTPS). A browser that already holds a
token keeps it, so logins started one after another in several tabs all
stay valid. Records last ten minutes, 1,024 per provider, oldest
evicted.
- The IdP posts its Response cross-site, and browsers withhold
`SameSite=Lax` cookies from cross-site POSTs. The ACS answers a
cookieless POST with a small page that re-posts the same fields once
from Pulse's own origin (no form action, so path prefixes survive; CSP
nonce on the script; a no-JS Continue button; a marker field stops a
second repost).
- `ProcessResponse` reads the answered request ID from the canonical
content goxmldsig verified for the signature covering the assertion
crewjam returns: the signed Response (or the Response in a signed
ArtifactResponse), otherwise that assertion's own
`SubjectConfirmationData`. It spends the record only when it is bound to
the presenting browser, and the login returns to the recorded `returnTo`
rather than the posted `RelayState`. A Response for another browser
leaves that browser's record intact.
- The signature verifier refuses signed content carrying a prefixed
attribute or namespace declaration named like an un-namespaced attribute
crewjam reads. encoding/xml fills such a field from any attribute with
that local name, and exclusive canonicalization leaves an unused
declaration out of the signed form. On the base commit, appending
`xmlns:IssueInstant` and `xmlns:NotOnOrAfter` to a two-hour-old signed
Response got it accepted, so with `allowIdpInitiated` on a captured
login could be replayed indefinitely.
- RelayState is now URL-escaped into the redirect, so a `returnTo` with
its own query string reaches the IdP whole.
- With `allowIdpInitiated` on, behaviour is otherwise unchanged: no
cookie or repost needed, posted `RelayState` honoured.
- The SLO LogoutRequest store from #2713 is generalised into a shared
record store; its `consume` semantics are unchanged.

Stacked on #2714 (on #2713, on #2707). Those three conflict with main in
`storage-recovery.md`, where their boundary notes and main's land at the
same anchor, so none can merge on its own. This branch carries a merge
of main that keeps both sets of notes; landing it lands the stack.

## Verification

- New tests in `internal/api/saml_handlers_more_test.go`,
`saml_service_test.go` and `audit_handlers_test.go`: default-settings
login through the repost, multiple tabs, refusal of another browser's,
unknown, unsolicited and replayed Responses (the refused browser's own
login still completes afterwards), the `xmlns:InResponseTo` spoof with
the Response signed and with only the Assertion signed, a
confirmation-less signed assertion spliced ahead of another browser's
assertion, assertion-only signed success, stale IdP-initiated Responses
with spoofed ages, repost escaping and CSP nonce, and the audit event
for a binding refusal.
`TestContract_SAMLManualIDPCertificateVerifiesIdPSignatures` now runs at
default settings instead of enabling IdP-initiated logins.
- Negative controls by `-overlay`: with the attribute refusal disabled
the stale-Response replay is accepted again; with the refusal disabled
and the binding read from crewjam's parsed fields the
`xmlns:InResponseTo` spoof logs in; the round-one binding code accepted
the spliced-assertion case as the other user.
- A real Chromium run against a test IdP on `127.0.0.1` and the SP
handlers on `localhost`, behind `SecurityHeadersWithConfig` in
production CSP mode: the IdP's POST arrived `Sec-Fetch-Site: cross-site`
without the login cookie, the repost arrived `same-origin` with it, and
the login landed signed in at
`/alerts?range=7d&saml=success&tab=history`.

## Known limits

- Records live in memory: a restart, a provider re-initialization, or a
login started on a host that does not share cookies with the configured
public URL leaves the Response unmatched, and the user signs in again.
- Two logins started at the same moment in a browser that holds no token
yet each mint one; the last cookie wins and the other login fails.
- As with OIDC login state, a sustained flood of unauthenticated login
starts can evict pending records.
- On plain HTTP, or HTTPS that Pulse does not recognise behind a proxy,
the cookie has no `__Host-` prefix, so a sibling subdomain could plant
one, the same exposure the session cookie has there.
2026-10-08 02:45:20 +01:00
rcourtman 8d6f489d3e Wrap long identity chips inside phone drawers (#2731)
Below the lg breakpoint, the inline drawer's Identity section rendered
alias and IP chips as hand-rolled nowrap spans inside a fixed-layout
table cell. Generated aliases such as `proxmox-lxc-docker:Production
West:pve1:113` or `k8s-production-1:deployment:redis-ffcd-b17c78f012`
are wider than that value cell on a phone, so the cell's hidden overflow
and the drawer's clipping content shell cut them off with no ellipsis.
The part that distinguishes two aliases is at the end, and a phone has
no hover to show the title.

- `MetadataBadge` (the contract owner of read-only chip whitespace
handling) gains an opt-in `wrap` mode: the chip is capped at its
container and breaks inside a token only where no space or hyphen fits.
Default stays nowrap; existing consumers get byte-identical classes.
- The drawer's IP, alias and alias-count chips in
`ResourceDetailSummary.tsx` compose `MetadataBadge`; identity values
wrap in full. Desktop drawers are unchanged (one line per alias).
- The shared template registry requires the consumer and forbids the old
hand-rolled chip classes there; the guardrail and unit tests cover the
wrap classes.
- `04-mobile.spec.ts` opens a Docker container drawer at phone width and
fails if any Identity-section element or text crosses the detail cell,
table wrapper or drawer shell (values that end in their own ellipsis
excepted).

Measured on a private mock estate at 390px: 53 cut chips across 92
Docker/Kubernetes/Proxmox/Machines drawers before, 0 identity chips past
the shell across 140 drawers at 390 and 768px after. Rebased on main
after #2703 (wider phone drawers): the new spec passes on the Pixel 5
and iPhone 12 projects and fails with the two runtime files reverted
(`proxmox-lxc-docker:...:113` still 71px past the cell).

Correlation-summary and redaction-label chips were checked and left
alone: redaction labels are a fixed five-word vocabulary, and the
correlation chips sit outside the nowrap table and break at spaces and
hyphens.
2026-10-08 02:37:34 +01:00
rcourtman 826252d274 Stop sending the ignored autonomous_mode chat field (#2730)
## Why

Since 4edf5f8265 (2026-07-11), `ChatRequest` in
`internal/api/ai_handler.go` has no `autonomous_mode` field. Go's
decoder drops it and `HandleChat` passes approval-required mode into the
chat service for every `/api/ai/chat` request (pinned by
`TestHandleChat_CannotElevateAutonomousModePerRequest`). The browser
still put `autonomous_mode` in the request body from its send options,
and the eval runner kept a per-step override that sent it too. Both read
as if the client chose the execution mode, but the server ignored them.

## What changes

- `AIChatAPI.chat` loses the `autonomousMode` positional parameter and
no longer writes `body.autonomous_mode`.
- `SendMessageOptions.autonomousMode`,
`ChatMessageRequestContext.autonomousMode`, and the clone, retry,
restore and Explain send-option copies are removed.
- `aiChatStore.context.autonomousMode` stays as a drawer-only flag.
Scoped handoffs still show "Chat: Ask first" and "Approval required
before any action." and hide the chat-actions warning.
- `internal/ai/eval` drops `Step.AutonomousMode`; the envelope test now
asserts the field is absent.
- `scripts/check-patrol-assistant-journey.mjs` asserts the Explain
request has no `autonomous_mode`.
- Contract prose (ai-runtime, api-contracts, agent-lifecycle,
storage-recovery, patrol-intelligence, frontend-primitives) no longer
describes `autonomous_mode:false` as a per-request override sent by the
browser. It now describes the server-set mode and the drawer disclosure.

No backend runtime change. The handler test proving a stale client's
`autonomous_mode:true` cannot elevate a request stays.

## Verification

- Private mock stack built from this branch, 1440x900 and 390x844. Every
`/api/ai/chat` body was captured with a page-level fetch wrapper.
- Plain chat with the saved level at Chat actions: body is `{prompt}`.
The pill and warning are unchanged.
- Patrol "Explain with Assistant": body keys are `handoff_context`,
`handoff_resources`, `prompt`. The drawer shows "Approval required
before any action." and "Chat: Ask first", with no warning.
- Follow-up turn: body keys are `model`, `prompt`, `session_id`, and the
disclosure persists.
- `go test ./internal/ai/eval/` passed.
- On pulse-dev: `tsc --noEmit` passed. `vitest` over
`src/components/AI`, `src/api/__tests__` and the aiChat store test
passed 2531 tests. In that parallel sweep, `AIChat.test.tsx` hit its 10s
`beforeAll` import timeout; run alone it passed 211/211.
- Read-only second-opinion review found no runtime regressions. It
flagged two contract sentences (undo/retry restoring an
"approval/autonomous override", and an overbroad "the body carries no
field"); both are fixed in this commit.
2026-10-08 02:34:34 +01:00
rcourtman 2d5ea5758b Name sensor setup notice nodes the way the nodes table does (#2727)
The outdated sensor setup notice on the Proxmox page named each node by
its Proxmox node name, while the nodes table directly below it, the
outdated agent notice beside it and Infrastructure settings label the
same node by its display name. With display names set, the notice read
"pve3 is using an older temperature monitoring setup..." above a table
with no row called pve3.

The notice now leads with the label the nodes table shows for the row
and adds the Proxmox node name in parentheses:

> West Production C (pve3) is using an older temperature monitoring
setup that cannot read SATA/SAS disk temperatures.

Both names are needed. The user has to match the notice to a table row,
and the nodes table switches to the Proxmox node name in its narrowest
layout (it showed `pve1`, `pve3` at a 375px viewport and display names
at 414px and up). They also have to find the host to re-run the setup
script on, which is the Proxmox node. Nodes without a display name read
as before ("pve3 is using ...").

The node name is left out only when the label already contains it as a
whole word, ignoring ASCII case, or as a domain's host part (`PVE1`,
`pve3.lan`, `Rack 4 (pve3)`). This is deliberately stricter than
`hasAlternateDisplayName`, which the guest table's group header uses.
That helper normalises punctuation, so it would show `PVE3` alone for
node `pve-3` and hide the host the user has to act on.

The notice's own test file is also registered as proof for the
frontend-primitives policy that covers the notice, matching the sibling
agent notice's test.

Verification:
- Unit tests cover the renamed case, labels that already contain the
node name, labels that only resemble it (`PVE3`/`pve-3`, `pve5-backup`,
`pve10`/`pve1`, a non-ASCII lookalike), blank names and
`nodeName`-before-`node` precedence. The platformPage, SharedPrimitives
guardrail, resource-boundary, nodes and Proxmox page contract suites
pass (396 tests), and eslint, prettier and tsc are clean.
- Browser pass on a private mock stack with three legacy-format nodes,
at 1440, 768, 414 and 375 wide on Overview and Storage. The notice reads
"Affected: Automation Lab Standalone (standalone2), West Production A
(pve1), West Production C (pve3)." with no overflow and no console
errors. Before control with main's two source files: "Affected: pve1,
pve3, standalone2."
2026-10-08 02:30:00 +01:00
rcourtman 19b963be71 Drop the dead recovery serializer and its stale contract prose (#2728)
The aggregate Recovery page (`frontend-modern/src/components/Recovery/`,
`features/recovery/useRecoverySurfaceState.ts`) was deleted on
2026-05-26 (66fb198cc6), but several contracts still described it as
live, and part of its plumbing survived.

**Code**
- Deletes the recovery query serializer from `routing/resourceLinks.ts`
(`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`,
`buildRecoveryRouteSearch`, `normalizeQueryBooleanFlag`). Nothing in
production called it: the Proxmox Backups tab owns its query through
`PROXMOX_BACKUPS_QUERY_PARAMS`, and the TrueNAS Protection tab keeps its
filters out of the URL.
- Deletes `utils/recoveryItemTypePresentation.ts`, whose only production
reader was that serializer, plus its tests and registry entries.

**Contracts** (frontend-primitives, storage-recovery, unified-resources,
alerts, cloud-paid)
- Passages naming the deleted shell, section owners, event-first
workspace, filter/column models, `view=inventory`/`view=events` route
state and the retired presenters now say what exists. Recovery rows
render in the Proxmox Backups and TrueNAS Protection tabs,
`useRecoveryPoints` is the only `/api/recovery/points` reader and
normalizes every response, and rules that only governed the deleted page
are gone or reduced to a reintroduction guard (storage-recovery
forbidden paths 9-11).
- `alerts.md` no longer claims `AlertResourceIncidentsPanel.tsx`
resolves chips through `buildResolvedResourceSurfaceLinks`. That helper
was deleted on 2026-05-16, and the panel imports nothing from
`resourceLinks.ts`.
- Leaves the embed paragraphs that rcourtman/Pulse#2726 rewrites
untouched. Both PRs merge cleanly together (checked with `git
merge-tree`, and the merged guardrails file passes).

**Proof**
- New assertions: no recovery-shaped export in `resourceLinks`;
`useRecoveryPoints` hands out normalized points (rendered hook with a
mocked transport) and is the only literal `/api/recovery/` reader
alongside `useProtectionPostures`; the retired hooks and helper stay
deleted; the Backups tab keeps day in the URL and range in component
state, with view tabs above the filter bar; the TrueNAS Protection table
keeps search, status and sort out of the URL; the incident panel imports
no route helper.
- Each assertion has a negative control that fails at the intended
assertion (15 in total). Targeted vitest, `tsc --noEmit`, eslint and
prettier pass on pulse-dev.
- Browser pass on a private Vite server from this tree at 1280x800 and
390x844. Covered `/proxmox/backups/date` (day selection, 1y range,
reload), `/proxmox/backups/coverage`, and `/truenas/protection` (filter,
row details, empty state). Receipt:
`frontend-modern/browser-verification/fe79952e5bb084d8.json`.
2026-10-08 02:23:18 +01:00
rcourtman d1ef487e09 Stop hidden tooltips and breakpoints listening to resize (#2724)
On `/proxmox` with 140 workload rows, 859 window `resize` listeners were
active after load (203 at phone width with 36 rows), so every window
resize ran one callback per listener:

- 699 from `useTooltipState.ts` (`useTooltipLayoutState`), which
registered a listener for every tooltip instance, including the hidden
`TooltipPortal` on each row's metric bars and sparklines.
- 156 from `useBreakpoint.ts`, where every call added its own listener
and width signal.

## Change

- Tooltip layout state listens only while its tooltip is visible and
re-reads the viewport when it opens. `useTooltipPortalState` uses the
same visibility gate as the portal's render (`when &&
supportsHoverTooltips()`), so a portal suppressed on a touch device
holds no listener either.
- `useBreakpoint` keeps one module-level width signal and shared memos,
fed by a single rAF-debounced listener while at least one caller is
mounted. A new caller syncs to the live `innerWidth`, so a caller
created after resizes nobody heard starts correct.

## Evidence

Live pass on a private mock-mode stack (headless Chromium,
add/removeEventListener wrapped to track live listeners by registering
frame):

| | before | after |
|---|---|---|
| 1440×900, 140 rows | 859 active, 859 callbacks per resize | 5 active,
5 callbacks |
| 390×844, 36 rows | 203 active | 5 active |

- An open row tooltip adds exactly one listener, stays open through
window resizes (its viewBox follows 1440×700 and 1380×640), and its
listener is removed on close.
- Crossing the phone breakpoint in both directions still switches the
workload table (`workload-table--desktop` ↔ `--mobile`) and each row's
layout.
- New tests: `useBreakpoint.test.ts` (one listener for 50 callers,
back-to-back resizes leave one pending frame, every caller crosses a
breakpoint, a new caller starts from the live width) and
`TooltipPortal.test.tsx` (hidden and touch-suppressed portals add no
listener, an open portal re-clamps on resize, a portal opened after an
unheard resize uses the live viewport). The listener assertions fail
against main.
- 198 frontend test files / 3620 tests pass across `components/shared`,
`components/Workloads`, the hooks, AppLayout, AIChat, Docker tables and
the boundary tests; prettier, eslint and tsc are clean.

Contract notes: `frontend-primitives.md` (tooltip owner) and
`performance-and-scalability.md` (new current-state section).
`useBreakpoint.ts` has no owning subsystem, so its rule lives in the
performance contract.
2026-10-08 02:23:06 +01:00
rcourtman 6d8dc0aa73 Correct stale platform-page embed contract prose (#2726)
Several v6 subsystem contracts described platform pages embedding
canonical surfaces through controls that no longer exist: a `tableOnly`
/ `embedded tableOnly` mode, a `showFilterToolbar` prop, a
`RecoverySurface`, `forcedPlatformFilter`, an embedded `StorageSurface`
at `/truenas/storage`, and a TrueNAS Protection tab built on a Recovery
surface. The aggregate Recovery surface was deleted on 2026-05-26
(66fb198cc6), and neither `WorkloadsSurface` nor `Storage` has an embed
mode.

What the code does, and what the contracts now say:

- **Workloads embeds** (`performance-and-scalability.md`): only the
Proxmox and vSphere Overview tabs embed `WorkloadsSurface`. Each page
builds its own `useWorkloadsState` with `forcedPlatform` and
`suppressPlatformFilter: true`, renders the one `WorkloadsFilter`
toolbar, and passes that state to the surface with
`suppressFilterToolbar`.
- **Platform-page rule** (`frontend-primitives.md`, with `cloud-paid.md`
now deferring to it instead of keeping a second copy): three tabs embed
a canonical surface (Proxmox and vSphere Overview, and Proxmox Storage
with `forcedSourceFilter`), and those tabs keep their rows on the
embeds. Every other tab renders a platform-owned table on the shared
platform-page primitives. The old "chrome-only, no per-family tables"
wording has not matched the code since the platform-first migration. The
bans on estate summary panels and forked table primitives stay.
- **Storage and recovery** (`storage-recovery.md`,
`unified-resources.md`): `/truenas/storage` renders
`TrueNASStorageTopologyTable`. Proxmox Backups reads `/api/backups/pve`,
`/api/backups/pbs` and the protection postures itself. TrueNAS
Protection feeds `TrueNASProtectionTable` from `useRecoveryPoints` with
the `truenas` platform forced.

Contract-only PRs never get their required checks, so a new case in
`platformOverviewLayout.guardrails.test.ts` pins these claims by parsing
the page surfaces with the TypeScript compiler API. It checks which
embeds each page has and which tab gates them, the platform lock on the
Workloads state and toolbar, `suppressFilterToolbar`, the source lock on
the Storage embed with no `forcedView`, the absence of an embed-mode
prop on either surface, and the wiring from the recovery hook to the
TrueNAS protection table. Twelve negative controls (removing the lock,
un-gating or moving a tab, re-adding the platform chip,
`suppressFilterToolbar={false}`, adding `forcedView` or a `tableOnly`
prop, embedding a `RecoverySurface`, emptying the protection points, and
others) each fail the case at the intended assertion. Two
behaviour-preserving edits still pass: a paren inside a comment and a
`>` inside a quoted attribute. The full guardrail file passes 19/19, and
the project typecheck and eslint are clean.

Out of scope here: the `storage-recovery.md` sections already rewritten
by #2718, and the wider prose about the deleted aggregate Recovery
page's internals (`components/Recovery/`, `useRecoverySurfaceState` as a
state owner, the `buildRecoveryRouteSearch` serializer paragraph). A
separate PR covers those, and the two merge cleanly in either order.
2026-10-08 02:20:07 +01:00
rcourtman b7bdf1b4eb Split a manually linked pair when the API unlinks it (#2725)
## Problem

`POST /api/resources/{id}/unlink` answered 200 "Resources unlinked" but
only recorded a `ResourceExclusion`. The manual link row stayed in
`resource_links`, and `ResourceRegistry.applyManualLinks` never consults
exclusions, so the pair stayed merged in the monitor's registry
rebuilds, the resources API and the websocket broadcast. Repro: link a
standalone agent into a VM, unlink it, then `GET /api/resources/<vm>`:
the agent facet is still there and the agent row is still gone.

Separately, the websocket broadcast coalesced host views with
`CoalescePresentationHostResources`, which applies no exclusions, while
REST's `ListForPresentation` applies the registry's, so a host pair the
operator split could be one broadcast row and two REST rows.

No frontend code calls `/api/resources/{id}/link` or `/unlink`
(`/api/agents/agent/unlink` is a different endpoint).

## Semantics

A resource pair carries one operator decision, and the later one
replaces the earlier: a link joins the pair, an exclusion (unlink,
report-merge) keeps it apart.

- Store writes: `AddExclusion` deletes the pair's link rows and
`AddLink` deletes its exclusion rows, in one transaction, matching both
ID orders; each also deletes a row of its own kind that canonical-ID
succession stored in the other order. The in-memory store does the same
and stamps `CreatedAt` like SQLite.
- Store reads: `GetLinks` / `GetExclusions` keep only each pair's latest
decision (`effectiveManualPairDecisions`), so rows written before this
change or rewritten onto one pair by succession resolve by their later
write; a tie keeps the pair apart.
- Registry: `loadOverrides` resolves its two reads together, so an
unlink committed between them cannot load both decisions into one
generation.
- Identity pins: while a Proxmox node was linked to an agent, the merged
resource's pin could record the node's endpoint hostname with the
agent's machine key. After unlink the node completed that key, minted
the agent's ID and folded into it by ID, a path no exclusion check sees.
A pin no longer completes the identity of a record the operator split
from the pinned resource.
- Broadcast: coalesces through `MonitorAdapter.CoalesceForPresentation`
on the read state that listed the rows when its registry is store-backed
(the store's adapter or a host-continuity overlay), else on the resource
store's adapter (the mock view lists through a store-less registry),
sharing `presentationExclusionFilter` with `ListForPresentation`.

In production REST seeds from the monitor's already-linked listing, so
an unlink shows on REST, `/api/state` and the websocket from the
monitor's next registry rebuild, the same generation in which a new link
reaches the broadcast.

## Known limits (unchanged, documented in the contract)

- Report-merge derives candidate IDs from the merged resource's type, so
it does not name the folded side of a cross-type manual link and cannot
undo one; unlink does.
- An agent's own declared node link (`resolveLinkedResource`) still
ignores exclusions.
- Presentation coalescing compares the IDs of the rows being merged, so
a third same-host fragment that merges first can carry an excluded pair
into one row (REST and broadcast now agree on this).

## Verification

- `TestResourceUnlinkSplitsManuallyLinkedPair` (new
`resources_unlink_test.go`) drives link, unlink and relink through the
API handlers against a `MonitorAdapter` on the same SQLite store,
rebuilt twice per step. It failed on main at "after unlink" exactly as
reported.
- `TestUnlinkSplitsHostPairWhosePinTookTheOtherSidesKey`: node/agent
pairs (IP and FQDN endpoints) through three rebuilds per step; both FQDN
cases fail with the pin guard removed.
- `TestManualPairDecisionReplacesThePreviousOne`,
`TestManualPairDecisionReadsResolveStoredConflictsByRecency`,
`TestRegistryResolvesDecisionsAcrossItsTwoReads`,
`TestMonitorAdapterCoalesceForPresentationHonoursExclusions`,
`TestBroadcastPresentationCoalesceHonoursMergeExclusions`,
`TestBroadcastPresentationCoalescePrefersTheListingReadState`.
- Negative controls (each fails with its fix reverted): store write
rule, newest-link-per-pair, registry combined resolution, pin guard,
broadcast exclusion coalesce, broadcast read-state preference,
store-less adapter fallback.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...` and `./internal/monitoring/...` pass.
- Live replay on private mock stacks built from main and from this
branch (`POST .../link`, `.../unlink`, `.../link` on a mock VM and a
standalone agent, then `GET /api/resources/{id}` for both). Main: after
unlink the VM still carries the agent facet and the agent row is still
404, unchanged 25 s later. This branch: after unlink the VM has no agent
facet and the agent row is back at once; relink folds it again.

Report-merge and agent-declared node links are left for separate
follow-ups.
2026-10-08 02:07:02 +01:00
rcourtman ce338b1496 Remove the unreachable Workloads group drawer and group pin (#2723)
## Why

`useWorkloadsState` defaulted `groupNodeDrawerMode` to `'inline'`, and
only that mode wired group-header hover preview, click-to-pin group
scope and an inline `NodeDrawer` under the group row. The only page that
used the default, the standalone `/workloads` page, was retired in May.
Both production mounts have passed `'disabled'` since host drawers moved
to each page's own hosts table: Proxmox in 84f627e22a (2026-05-18, "Move
Proxmox host drawers to the host table") and vSphere in d96d5612fd
(2026-05-23). The disabling was deliberate on both pages, so this
removes the dead path instead of re-enabling it. The demand ledger has
no entry asking for workload group hover or pin.

The only way left to reach the group pin was a hand-typed
`?summaryGroup=` URL. No link builder passes it, and row expansion
stopped writing it in 86ac84f68e.

## What changes

- `WorkloadPanel`: drops the inline `NodeDrawer` mount, the group hover
and pin handlers, `aria-expanded`, `data-summary-group-series-count`,
`data-summary-row-active` on group rows, and the group-member state
passed to `GuestRow`. Group rows stay identity-only dividers and keep
`data-summary-group-id`, so clicking one still leaves an open guest
drawer alone (`summaryTableFocus` ignores that selector).
- `useWorkloadSelectionState`: drops the hovered and focused group
scope, `summaryGroup` hydration and the related effects. It no longer
passes group options to the shared bridge; Storage still uses them.
- `useWorkloadsState`: drops the option, `getNodeTemperatureThresholds`
(it only fed the removed drawer), and the `summaryGroupScopes` memo,
which rebuilt a scope for every group on every filtered-guest change.
- Removes `buildWorkloadSummaryGroupScope(Map)`,
`workloadsHasVisibleWorkloadGroupScope`, the `GuestRow` group-member
attribute and the workloads `summaryGroup` route param.
- Proxmox and vSphere stop passing the option. Their filter bars'
pinned-selection state now reads only the selected guest.
- `NodeDrawer` stays live in `ProxmoxNodesTable`. The temperature
guardrail now pins that live mount instead of the dead one.
- Contracts: rule 8 and the Workloads, Proxmox and vSphere clauses now
describe static dividers. Stale sentences about grouped node rows
opening the node drawer, group-row emphasis, and Workloads selection
writing back to the route are corrected.

## Verification

- Before/after browser pass on pulse-dev: embedded builds on mock demo
stacks, Proxmox and vSphere overviews, at 1440x900 and 390x844.
  - Hover, click and tap on group dividers do nothing on both builds.
- Guest drawer, Escape, guest-row hover emphasis, `?resource=` links and
the Proxmox nodes-table `NodeDrawer` behave the same on both builds.
- Clicking a divider with a guest drawer open keeps the drawer: 10/10 on
each build.
- The hand-typed `?summaryGroup=` URL pinned 18 Proxmox rows on main and
pins none now.
- Receipt: `frontend-modern/browser-verification/db4ee39cb5db006c.json`.
- Frontend typecheck passes. The targeted suites (Workloads, proxmox,
vmware, platformPage, shared, routing, utils, hooks, Storage) pass on
pulse-dev except one Proxmox backups windowing test that failed once at
load ~40 and then passed 2/2 alone.
- A full-suite run on the earlier base passed except files that also
fail on unmodified main or only under load. `useReportingPanelState`
fails on main there too.
- Commit hook battery and governance audits pass.
- Two rounds of second-opinion review. Round 1 caught a stale test pin
and stale perf-contract prose; round 2 caught one inaccurate
scroll-restore sentence. All are fixed here.

Follow-ups raised separately: the built-in `WorkloadsSurface` filter
toolbar is also unreachable, since both mounts pass
`suppressFilterToolbar`. The quarantined E2E spec 48 still targets the
retired `/workloads` page.
2026-10-08 01:58:31 +01:00
rcourtman 64458fc13f Retire the summary hover E2E spec pinned to removed surfaces (#2722)
Deletes the quarantined
`tests/integration/tests/48-summary-hover-selection.spec.ts` and its
`QUARANTINED_SPECS` entry.

None of its nine tests could pass against the current app. Each one
opens `/workloads` or `/storage`, which now show Page Not Found, or
`/infrastructure`, which redirects to the first platform page. Each then
waits for a `workloads-summary`, `infrastructure-summary` or
`storage-summary` card that no longer renders.

The storage pool-group pin test was the only candidate for a port to
`/proxmox/storage?group=node`. Checked against the live page, a
group-header click there collapses the group as it pins it. In that run
the table went from 17 to 14 rows and showed 0 pinned member rows. The
test expects the row count unchanged and every member pinned. It also
asserts summary chart series counts, and those charts no longer exist.
The pin behaviour that still works (hover preview, `summaryGroup`,
pinned emphasis after expanding, Clear selection) is already covered in
`Storage.test.tsx`.

Tier ledger check (`check-e2e-tiering.mjs`, run on pulse-dev):
- origin/main: passes, 102 spec files, 3 quarantined
- this branch: passes, 101 spec files, 2 quarantined, with stable and
probation test counts unchanged (366 / 553)
- spec deleted but ledger entry kept: fails with `QUARANTINED_SPECS has
unknown specs: 48-summary-hover-selection.spec.ts`
2026-10-08 01:29:20 +01:00
rcourtman 39b6007f5d Pin each manual-link side from its own identity (#2719)
Stacked on #2715 (its commit is this branch's parent; whichever lands
first, the other still merges).

## Problem

A manual link merges two resources for display, but
`PersistIdentityPins` pinned the merged identity. A link primary was
pinned with the folded resource's machine key, and the store's
one-owner-per-key rule deleted the folded resource's own pin.

For a Proxmox node with a qualified endpoint
(`https://pve1.example.test:8006`) linked to a host agent, the node's
pin records the endpoint, so the next rebuild completed the agent's
machine key from it, minted the agent's machine-derived ID and merged
the two by ID before the link applied. With the node as primary,
succession then rewrote the link into a self-link on the agent's ID; in
either direction, removing the link left the pair merged. Reproduced
through `MonitorAdapter.PopulateSnapshotAndSupplemental` with a
`MemoryStore` on #2715's head.

## Change

- `applyManualLinks` captures each side's own pin before the merge
(`recordLinkOwnPin`, first capture per rebuild wins), and
`PersistIdentityPins` writes those instead of the merged projection,
keeping a pinnable folded side pinned under its own canonical ID.
- Link sides write through a new
`ResourceStore.ReplaceResourceIdentityPins` (both stores). Like an
upsert the row keeps fields the side's own pin leaves empty, but it
drops a strong key another resource holds this rebuild, which is how a
key an earlier release borrowed from the merged identity leaves the row.
- A folded side drops a key a live resource or an earlier folded side
already holds instead of trading it every rebuild, and writes no pin
when that would cost it its machine evidence (a cluster-only row would
be open to cluster-slot succession).
- Proxmox auto-links (LinkedAgentID / identity matcher) merge before any
manual link applies, so their boot-window pins are unchanged
(`TestIssue1753*`, `TestMonitorAdapterRebuildPersistsIdentityPins`
pass).

Known limits, written into the contract: a node row an earlier release
already filled with the agent's key still merges the pair by ID while
the node can complete from it; two live resources sharing a strong key
still alternate, as on main; evidence a later record pass of the same
rebuild merges into an already-joined row reaches neither side's pin.

## Verification

- New tests in `registry_merge_policy_test.go` (standalone and clustered
node, both link directions, three rebuilds with no steady-state pin
writes, then unlink separates the pair; shared-key, kept-field,
hostname-guard, machine-loss and borrowed-key cases) and `store_test.go`
(replace vs upsert on memory and SQLite).
- Each new test fails against #2715's runtime files via `go test
-overlay`; each guard rule fails its own test when removed.
- `internal/unifiedresources` full package, `internal/monitoring`
link/identity tests, `internal/api/resourceapi`, `go build ./...` and
`go vet` pass.
- Contract: `unified-resources.md` clause 25 and "Operator links reach
record-ingested resources".
2026-10-08 00:45:22 +01:00
rcourtman bb35a5c006 Correct stale Storage embed and History range contract prose (#2718)
Three contract passages described toolbar controls that no longer exist.

**storage-recovery.md (StorageSurface embed).** The contract said
platform pages opt into the Storage toolbar through a
`showFilterToolbar` prop on `StorageProps`, keep `tableOnly` to hide a
storage summary section, and get a chart-collapse control. It also
mentioned `RecoverySurface`, `forcedPlatformFilter`, and an embedded
`StorageSurface` at `/truenas/storage`. On main:
- `Storage.tsx` always renders `StoragePageControls`; only kiosk mode
hides it.
- There is no summary section and no chart-collapse control.
- The Proxmox Storage tab is the only embed. It passes
`forcedSourceFilter`, `resourceSource`, `suppressNodeFilter`,
`filterAriaLabel` and search copy.
- `/truenas/storage` renders `TrueNASStorageTopologyTable`.

The rewrite describes what `Storage.tsx`, `StoragePageControls.tsx` and
`useStoragePageFilters.ts` actually do:
- The forced source is read as the route source and never written back
to the URL.
- The Source chip is auto-suppressed and does not surface the clear
action.
- `suppressNodeFilter` drops the Node chip and resets a selected node to
all nodes.

**performance-and-scalability.md and frontend-primitives.md (Workloads
History range).** Both said the sparkline range shows only while Trends
is active and carries a `Trend range` label. `WorkloadsFilter` actually
shows it in Trends, and in Bars at desktop widths while Row hover is
`History`. Its label is `History`.

**Test.** A new rendered test in `Storage.test.tsx` pins the embed
behaviour the rewrite claims. Five negative controls each fail it:
- dropping the forced-source suppression
- dropping the node reset
- unlocking the route source
- gating the toolbar off
- keeping the Node filter

The `History` label was already pinned by `WorkloadsFilter.test.tsx` and
`WorkloadsSurface.performance.contract.test.tsx`. Another stale test
title, "table-only embedding", is renamed.

Sibling stale passages are left for a follow-up: `embedded tableOnly` in
frontend-primitives.md and cloud-paid.md, the WorkloadsSurface
`showFilterToolbar` text in performance-and-scalability.md, and the
TrueNAS StorageSurface line in unified-resources.md.
2026-10-08 00:44:18 +01:00
rcourtman aac5ca5fc2 Colour pulse-relaxed guests by the thresholds their alerts use
The pulse-relaxed Proxmox tag lifts a guest's CPU, memory and disk alert
triggers to a 95/92/95 floor, but the browser coloured bars from the
configured thresholds alone. A relaxed guest at 88% memory showed a red
bar while the alert engine stayed silent until 92%, and its Alerts
thresholds row kept showing 80/85/90 with no sign that the tag raised
them (issue #863, comment of 2026-01-16).

- resolveMetricDisplayThresholds takes the tags the engine reads for a
  guest and applies the backend's applyRelaxedGuestThresholds step:
  unset gets the floor, a lower trigger is raised and keeps its clear
  point unless the raised trigger reaches it, and Off stays Off.
- getWorkloadAlertPolicyTags passes tags only for Proxmox VMs and LXCs,
  the only guests whose tags reach alert policy. vSphere and TrueNAS
  VMs and containers keep their thresholds whatever they are tagged.
- A relaxed guest's Alerts thresholds row and phone card add one line
  naming the tag and the thresholds it raises. It follows unsaved edits,
  while the cells keep the configured values the editor changes.
2026-10-08 00:35:28 +01:00
rcourtman 32fad38aa4 Merge remote-tracking branch 'origin/main' into refactor/drop-unread-patrol-state-members 2026-10-08 00:34:24 +01:00
rcourtman 25fba3bf82 Record the browser pass for the merged Patrol state hook
Merging main brought in its own removals of summaryStats and the
correlation supporting read, so the hook's final content matches neither
parent and needs a receipt bound to that content. Builds of origin/main
and of this merged tree ran on pulse-dev on private demo stacks: the
Patrol page, Open in Assistant, tab hide/show and the stale-data banner
with Retry behave the same on both at 1440 and 390 px, with the same
endpoint set and no console errors.
2026-10-08 00:33:25 +01:00
rcourtman 4e95ca45cb Stop Storage scrolling back to open pools and reopening groups
On the Storage pools view, an open pool pulled the page back to itself a
second or two after the operator scrolled away, and collapsing the open
pool's group bounced it straight back open. The summary focus bridge ran
the consumer's revealActiveSeries callback inside a tracked effect, so
everything Storage's callback reads (the pool-to-group lookup that every
live update rebuilds, the expanded group set) re-ran the reveal and its
inline-detail scroll. The reveal now subscribes only to the focused
series id and the table root, and runs the callback and scroll
untracked, so it runs once per deliberate focus change. Its cleanup is
registered up front, so a focus change or unmount also cancels pending
settle frames.

Fixing that exposed a second cause of the bounce: the expansion state
re-added every group key missing from its expanded set whenever a live
update rebuilt the groups, so any collapsed group reopened within about
ten seconds. It now stores only the groups the operator collapsed and
derives the expanded set, so new groups still open by default. Group keys
are bare labels, so a grouping change clears the collapsed set rather than
carrying collapse state into another grouping or hiding the headerless
"All" group.

Measured on a 12-node mock stack: after scrolling away from an open pool
the shell stays at scrollTop 0 for 12 s (was 0 -> 1111 within ~1.5 s),
and a group collapsed by its button or header stays collapsed for 16 s.
The new bridge, Storage and expansion tests fail on the previous code.
Filter changes do not re-run the reveal either, so a focused pool whose
row stays filtered out past the reveal's retry window is not revealed
when the filter clears; storage links reset the URL-owned filters, so
only a link that carries an excluding filter gets there.
2026-10-08 00:23:41 +01:00
rcourtman 1eb4a84fe6 Drop the dead recovery serializer and its stale contract prose
The aggregate Recovery page and its route-state owner were deleted on
2026-05-26, but RECOVERY_QUERY_PARAMS, parseRecoveryLinkSearch and
buildRecoveryRouteSearch stayed in resourceLinks.ts with no production
caller, which kept recoveryItemTypePresentation.ts alive as their only
reader. Both go.

The frontend-primitives, storage-recovery, unified-resources, alerts and
cloud-paid contracts still described the deleted page as live: its shell,
event-first workspace, section owners, filter and column models, recovery
route state, and the incident panel's link chips. Those passages now say
what exists. Recovery rows render in the Proxmox Backups and TrueNAS
Protection tabs, useRecoveryPoints decodes every recovery-points read, and
rules that only governed the deleted page are gone or reduced to a
reintroduction guard.

New assertions pin the rewritten claims: resourceLinks exports nothing
recovery-shaped, the recovery-points hook hands out normalized points and
is the only reader, the retired hooks stay deleted, each tab keeps its own
view and filter state, and the incident panel imports no route helpers.
2026-10-08 00:17:30 +01:00
rcourtman d95a316118 Stop sending the ignored autonomous_mode chat field
Since 4edf5f8265, ChatRequest in internal/api/ai_handler.go has no
autonomous_mode field: Go's decoder drops it and HandleChat passes
approval-required mode into the chat service for every /api/ai/chat
request. The browser still serialized autonomous_mode from its send
options, and the eval runner kept a per-step override that sent it too,
so both read as if the client chose the execution mode while the server
ignored it.

Drop the field from AIChatAPI.chat, SendMessageOptions,
ChatMessageRequestContext and the clone, retry and restore helpers, and
Step.AutonomousMode from internal/ai/eval. aiChatStore.context's
autonomousMode stays as a drawer-only flag, so scoped handoffs still show
"Ask first" and "Approval required before any action." Tests now assert
the request carries no autonomous_mode; the backend test proving a stale
client's autonomous_mode:true cannot elevate a request stays.

Contract prose that described autonomous_mode:false as a per-request
override set by the browser now describes the server-set mode and the
drawer disclosure.
2026-10-08 00:17:08 +01:00
rcourtman 59cc3ce10b Wrap long identity chips inside phone drawers
Below the lg breakpoint, the inline drawer's Identity section rendered
alias and IP chips as hand-rolled nowrap spans inside a fixed-layout
table cell. A generated alias such as
proxmox-lxc-docker:Production West:pve1:113 or
k8s-production-1:deployment:redis-ffcd-b17c78f012 is wider than that
value cell on a phone, so the cell's hidden overflow and the drawer's
clipping content shell cut it off with no ellipsis. On a 390px mock
estate 53 chips across Docker and Kubernetes drawers were cut. The part
that tells two aliases apart sits at the end, and a phone has no hover
to show the title.

MetadataBadge owns read-only chip whitespace handling, so it gains an
opt-in wrap mode that caps the chip at its container and breaks inside
a token only where no space or hyphen fits. The drawer's IP, alias and
alias-count chips now compose MetadataBadge, and the identity values
wrap in full. The default stays nowrap, so existing badge consumers are
unchanged, and desktop drawers still show each alias on one line.

The shared template registry now requires ResourceDetailSummary to
compose MetadataBadge and forbids the old hand-rolled chip classes
there. A mobile integration check opens a Docker container drawer and
fails if any Identity-section element or text crosses the detail cell,
table wrapper or drawer shell; it fails on the old code and passes with
this change.
2026-10-08 00:17:01 +01:00
rcourtman 077c3a8959 Merge origin/main into fix/saml-acs-request-binding
Brings the branch up to the main it lands on; the merge is clean.

Contract-Neutral: Merge of origin/main into the SAML stack: main's changes were governed when they landed, and the merge is clean
2026-10-08 00:14:01 +01:00
rcourtman d72d355ccf Merge origin/main into fix/saml-acs-request-binding
The SAML stack this branch builds on appends its boundary notes to
storage-recovery.md at the anchor main used for the linked-agent stale
flag note. Both sets of notes are kept, the SAML ones first.

Contract-Neutral: Merge of origin/main into the SAML stack: main's changes were governed when they landed, and the only resolution keeps both sets of storage-recovery boundary notes
2026-10-08 00:13:11 +01:00
rcourtman 4d4e2e0159 Merge remote-tracking branch 'origin/main' into fix/sensor-setup-notice-names 2026-10-08 00:10:35 +01:00
rcourtman 371435e1eb Name sensor setup notice nodes the way the nodes table does
The outdated sensor setup notice named each node by its Proxmox node
name ("pve3") while the nodes table below it, the outdated agent notice
beside it and Infrastructure settings label the same node by its
display name ("West Production C"). A user with display names set could
not tell which row the notice meant.

The notice now leads with the label the nodes table shows for the row
(the resource name, else the id) and adds the Proxmox node name in
parentheses: "West Production C (pve3)". Both names are needed. The
nodes table switches to the Proxmox node name in its narrowest layout,
and the setup script is re-run on that node, so the exact native name is
what the user needs to find the host.

The node name is left out only when the label already contains it as a
whole word, ignoring ASCII case, or as a domain's host part ("PVE1",
"pve3.lan"). This is deliberately stricter than hasAlternateDisplayName
in the guest table's group header, which normalises punctuation and would
show "PVE3" alone for node "pve-3", hiding the host to act on.

The notice's own test file was never registered as proof for the
frontend-primitives policy that covers the notice, unlike the sibling
agent notice's test, so it is added to that policy's exact_files.
2026-10-08 00:08:09 +01:00
rcourtman 4cf1c318fb Correct stale platform-page embed contract prose
Several v6 contracts still described platform pages embedding canonical
surfaces through controls that no longer exist: a `tableOnly` /
`embedded tableOnly` mode, a `showFilterToolbar` prop, a `RecoverySurface`,
an embedded `StorageSurface` at /truenas/storage, and a TrueNAS Protection
tab built on a Recovery surface. The aggregate Recovery surface was deleted
on 2026-05-26 and neither WorkloadsSurface nor Storage has an embed mode.

The platform-page rule in frontend-primitives.md (and its cloud-paid
counterpart) now says what the pages do: the Proxmox and vSphere Overview
tabs embed WorkloadsSurface with the page owning the workloads state and
the one WorkloadsFilter toolbar, the Proxmox Storage tab embeds
StorageSurface with forcedSourceFilter, and every other tab, including
/truenas/storage, Proxmox Backups and TrueNAS Protection, renders a
platform-owned table on the shared platform-page primitives. The old rule
said pages only embed canonical surfaces and must not add per-family
tables; that has not been true since the platform-first migration, so the
rule now names the three embeds, keeps those tabs' rows on them, and keeps
the bans on estate summary panels and forked table primitives. cloud-paid
defers to that rule instead of carrying a second copy.

The new guardrail case pins those embeds per page, the absence of an
embed-mode prop on both surfaces, the platform lock on the Workloads
embeds, and the recovery tabs' platform-owned tables, so the contract and
the code cannot drift apart silently again.
2026-10-08 00:07:54 +01:00
rcourtman 58ee64fc1c Split a manually linked pair when the API unlinks it
POST /api/resources/{id}/unlink recorded only an exclusion. The link row
stayed, and applyManualLinks never consults exclusions, so the monitor's
rebuilds, the resources API and the websocket kept the pair folded while
the API answered "Resources unlinked".

A pair now carries one operator decision. AddExclusion deletes the pair's
links and AddLink its exclusions, in one transaction and in either ID
order. Reads keep each pair's latest decision, so rows written before
this change, or rewritten onto one pair by canonical-ID succession,
resolve by their later write, and a registry resolves its two reads
together so an unlink landing between them cannot load both.

While a Proxmox node was linked to an agent, the merged resource's
identity pin could take the agent's machine key. After unlink the node
completed that key, minted the agent's ID and folded into it by ID, where
no exclusion check runs. A pin no longer completes a record the operator
split from the pinned resource.

The broadcast coalesced host views without exclusions while REST's
ListForPresentation honoured them, so a split host pair was one websocket
row and two REST rows. The broadcast now coalesces through the read state
that listed the rows, with the same exclusion filter.
2026-10-08 00:07:49 +01:00
rcourtman 9daa8154c8 Merge remote-tracking branch 'origin/main' into perf/shared-resize-listeners 2026-10-08 00:00:38 +01:00
rcourtman 03e4920460 Stop hidden tooltips and breakpoints listening to resize
On /proxmox with 140 workload rows, 859 window resize listeners were
active after load (203 at phone width with 36 rows), so every window
resize ran one callback per listener and each set signals. 699 came
from tooltip layout state registering a listener for every instance,
including the hidden TooltipPortal on each metric bar and sparkline,
and 156 from every useBreakpoint() call adding its own listener.

Tooltip layout state now listens only while its tooltip is visible
(a portal suppressed on a touch device counts as hidden) and re-reads
the viewport when it opens. useBreakpoint keeps one module-level width
signal fed by a single rAF-debounced listener while any caller is
mounted, and a new caller syncs to the live innerWidth.

The page now holds 5 listeners at both widths. An open tooltip adds
one and removes it on close, still follows window resizes, and
crossing the phone breakpoint still switches the workload table and
row layout.
2026-10-07 23:58:00 +01:00
rcourtman 03767f1e95 Remove the unreachable Workloads group drawer and group pin
useWorkloadsState defaulted groupNodeDrawerMode to 'inline', but only
the standalone /workloads page used that default, and it was retired
in May. Both production mounts, the Proxmox and vSphere overviews,
have passed 'disabled' since host drawers moved to each page's own
hosts table (84f627e22a, d96d5612fd). So the inline NodeDrawer under a
group row, the group-header hover preview and click-to-pin never ran.

The only way left to reach the group pin was a hand-typed
?summaryGroup= URL: no link builder passes it, and row expansion
stopped writing it in May (86ac84f68e). On the Proxmox overview it
pinned the group's rows; on vSphere it pinned nothing.

Drop the option, the inline NodeDrawer mount and the group hover/pin
handlers from WorkloadPanel; the group scope state, route hydration
and effects from useWorkloadSelectionState; the per-group summary
scope map that useWorkloadsState rebuilt on every filtered-guest
change; the GuestRow group-member attribute; and the workloads
summaryGroup route param. Group rows stay identity-only dividers and
keep data-summary-group-id, so clicking one still leaves an open guest
drawer alone. NodeDrawer stays live in the Proxmox nodes table, and
the temperature guardrail now pins that live mount.

Contract prose now describes static dividers in rule 8 and the
Workloads, Proxmox and vSphere clauses, and corrects stale sentences
about grouped node rows opening the node drawer and about Workloads
selection writing back to the route.
2026-10-07 23:50:50 +01:00
rcourtman 1a094646d7 Let only POST narrate single-resource reports with Pulse Assistant (#2716)
## What

Single-resource report generation (`/api/admin/reports/generate`) now
takes `POST` with a JSON body as its canonical transport, and only
`POST` may narrate a PDF with Pulse Assistant.

- **POST**: the same fields as before (`resourceType`, `resourceId`,
`format`, `metricType`, `start`, `end`, `title`) in a JSON body, decoded
with the `generate-multi` rules (1MB cap, unknown fields and trailing
payload rejected). When Pulse Assistant is configured, a PDF's executive
summary is narrated by the AI provider as before.
- **GET**: still accepts the same fields as query parameters and returns
the same report, but always uses the deterministic summary. The PDF
shows a short note in place of the "Configure Pulse Assistant" tip,
which would be wrong for a tenant that already has the Assistant
configured.
- **GET never resolves the AI service at all.** It reads Patrol findings
only from a tenant AI service that is already running, through a new
`AISettingsHandler.ExistingAIService` lookup. Constructing a tenant
service through `GetAIService` can list provider models or start
background discovery, so a report GET must not trigger it. The router's
reporting wiring moves into `Router.wireReportingAIResolvers` so a test
can drive it.
- **Other methods**: `405` with `Allow: GET, POST`.
- The shared report body decoder now also returns `body_too_large`,
rather than `invalid_body`, when the cap is hit while reading past a
valid value.
- The reporting catalog advertises
`performanceReport.singleResourceMethod: "POST"`, and the settings UI
follows it. A catalog without the field, and the legacy fallback
catalog, keep the GET query transport that older backends accept. Fleet
reports were already `POST`.
- `docs/API.md` and `docs/MSP.md` (and their shipped copies) document
both methods.

## Why

Before this change, a GET for a PDF resolved the tenant's AI narrator.
That made a paid provider call and recorded a usage event in the AI cost
ledger. GET passes the demo-mode guard and the CSRF check, and a
SameSite=Lax session cookie is sent on cross-site top-level GET
navigations. So following a link could spend a signed-in admin's AI
budget.

GET stays available rather than returning `405` because the API
reference and the MSP guide documented it for scripted exports. Breaking
those scripts would add no protection: CSV is never narrated, and GET no
longer reaches the provider.

## Verification

- `TestContract_SingleReportAINarrationRequiresPOST` drives a real
report engine with a counting narrator. GET, HEAD, PUT and DELETE make
zero narrator calls and POST makes one. The GET narrative is heuristic
and carries the note. As a negative control, the same test fails against
the handler on `main` via `go test -overlay` ("GET report called the AI
narrator 1 times, want 0").
- `TestContract_SingleReportGETDoesNotConstructTenantAIService` drives
the router's real resolvers with an uncached tenant: GET constructs no
tenant AI service, while POST resolves one. A negative control that
swaps `ExistingAIService` back to `GetAIService` fails it ("GET report
constructed 1 tenant AI services, want 0").
`TestAISettingsHandler_ExistingAIServiceNeverConstructs` covers the
lookup itself.
- Handler tests cover the POST body rules (empty, unknown field,
trailing value, form-encoded body, fields only in the query, oversized
padding) and the `Allow` header. The catalog contract snapshot includes
the new field.
- Frontend tests cover POST bodies, the GET transport for catalogs that
name GET, parser defaults and validation, and the legacy fallback.
`docsLinks.test.ts` ties the docs to the handler.
- Browser pass on a private stack: real login, dev Pro licence, and
Pulse Assistant pointed at a local stub OpenAI-compatible provider that
logs every call. Checked at 1280×800 and 390×844:
- Generating a single-resource PDF from Settings → Data & Reports sends
one `POST` with a JSON body, downloads the PDF and calls the provider
once. The downloaded PDF carries the AI narrative.
- CSV makes no provider call. The fleet report still posts to
`generate-multi`.
- A top-level browser navigation to the GET URL with the session cookie
downloads the deterministic PDF, with the new note, and makes no
provider call.
- HEAD, PUT, DELETE and PATCH return `405`, and a session POST without
the CSRF header returns `403`.
  - The AI usage summary lists `report_narrative` only for the UI POSTs.
- Full `./internal/api/...` and `./pkg/reporting` run on the dev VM:
everything passed apart from two tests that fail there for environment
reasons in code this change does not touch
(`TestServerInfoEndpointReportsDevelopment` needs `.git`; a `configapi`
shell-hangup bootstrap test).

The same class of issue on the Assistant session routes
(`/api/ai/sessions/{id}/summarize` and friends) is handled separately in
#2670.
2026-10-07 23:48:46 +01:00
rcourtman 4292c180b2 Retire the summary hover E2E spec pinned to removed surfaces
The quarantined 48-summary-hover-selection spec never ran, and none of
its nine tests could pass against the current app. Each one opens
/workloads or /storage, which no route matches since the platform-first
IA, or /infrastructure, which is now only an entry route that redirects
to the first platform page, and then waits for the workloads,
infrastructure or storage summary cards that were deleted with the
standalone surfaces.

The storage pool-group pin test was the only candidate for a port to
/proxmox/storage, but it asserts behaviour that page does not have. A
group-header click there collapses the group as it pins it, so the
member rows the test expects to stay visible and pinned are hidden, and
there are no summary charts left for the pin to rescope. The live pin
(hover preview, the summaryGroup URL param, pinned member emphasis
after expanding, Clear selection) is already covered in Storage.test.tsx.

Deleting the file takes its QUARANTINED_SPECS entry with it, because
the tier check rejects ledger entries for specs that do not exist.
2026-10-07 23:47:38 +01:00
rcourtman 1a48d27aa0 Merge remote-tracking branch 'origin/main' into refactor/drop-unread-patrol-state-members 2026-10-07 23:45:03 +01:00
rcourtman fd36869223 Judge source merges by the ingest's stale thresholds (#2717)
Stacked on #2715 (its commit is the parent of this one); either can land
first.

## Problem

`mergeInto` passed nil thresholds to `mergeMetrics`, so every source
merge into an existing row (snapshot and record ingest alike) judged
metric freshness by the registry defaults, while the stale pass and
manual links used the monitor's configured thresholds. Two visible
results:

- A Proxmox node polled every two minutes (four-minute threshold) merged
with its auto-linked agent showed the silent agent's CPU and memory,
while the stale pass called the node's poll current.
- A supplemental refresh (`MonitorAdapter.PopulateSupplementalRecords`,
and the read-state overlay) of a linked vSphere VM kept the in-guest
agent's memory over vSphere's current reading, where a full rebuild from
the same records shows vSphere's.

## Change

- Snapshot and record ingest hold the caller's thresholds on the
registry while they run (the same per-pass pattern as the guest loop's
agent index), and `mergeInto` passes them to `mergeMetrics`. The
snapshot helpers that reach `mergeInto` keep their signatures.
- The stale pass and the metric merge share one threshold rule,
`sourceStaleThreshold`, so the merge also honours a sighting's expected
update interval (slow Proxmox disk inventory), as the stale pass already
did.
- Outside mock mode the monitor configures only Proxmox, PBS and PMG, so
those are the production sources this changes; mock mode also configures
TrueNAS, vSphere and availability.

## Proof

- `TestMonitorAdapterSourceMergesUseConfiguredStaleThresholds`: rebuild,
supplemental refresh and overlay of a linked vSphere VM, plus a node
with an auto-linked agent, through
`NewMonitorAdapterWithStaleThresholds`.
- `TestSourceMergesJudgeFreshnessByTheIngestStaleThresholds`: snapshot
node+agent merge with and without thresholds.
- `TestMetricMergeFreshnessMatchesTheStalePass`: the merge and the stale
pass agree for default, configured and expected-interval sightings.
- Negative control with #2715's `registry.go`: the supplemental,
overlay, node+agent, snapshot and expected-interval cases fail; the
rebuild case and the defaults case pass on both.
- `go test ./internal/unifiedresources/... ./internal/monitoring/...
./internal/api/resourceapi/... ./internal/mock/...` on pulse-dev.
2026-10-07 23:40:40 +01:00
rcourtman 7f45148d15 Drop the unused Patrol readiness-failure object behind the toggle toast (#2687)
Stacked on #2686 (which stacks on #2682 and #2674); this branch carries
their commits until they land. Only the top two commits are new.

## What

`buildPatrolSettingsReadinessFailure` built a full
`PatrolConfigurationFailureInput` for the Patrol page's inline "needs
attention" state. That state lost its render in 27ba85ddc6 (2026-06-23),
when the Configure Patrol popover was removed. Since then its only
production caller, the Patrol on/off toggle, has read just `.message`,
and that was always the fixed copy "Patrol setting was saved, but Patrol
is not ready to run."

- `getPatrolSavedReadinessWarning` returns that message when the saved
response reports `patrol_readiness` `not_ready`, and the toggle shows it
as a warning toast, as before.
- `buildReadinessDetails` and the `PatrolConfigurationFailureInput` type
are removed. Its last other consumer,
`getPatrolConfigurationFailureInlineDetails`, goes in #2686.
- The Assistant drawer still restores saved
`patrol_configuration_failure` sessions. Nothing on that path changed.

## Runtime delta

- Production builds of the parent and this branch differ in one chunk,
`AIIntelligence.js` (the Patrol page), which is 935 bytes smaller. After
normalising chunk hashes, SRI strings and minified identifiers, the only
change is the removed builder and details helper, replaced by one status
check. The toast string is byte-identical.
- The minifier had already folded `message || readiness.summary ||
default` to the caller's constant, so the readiness-summary fallback was
unreachable.
- The toggle no longer reads seven Patrol signals to build the object it
discarded.
- `check-bundle-size.mjs` passes.

## Verification

The same Playwright script ran against embedded builds of the parent and
this branch, at 1440 and 390 px, on a demo mock stack. Responses were
stubbed with `page.route`. All 31 observed fields were identical between
the two builds. In every case only a toast appears, with no inline
alert, no "needs attention" text and no Assistant action:

- Patrol on/off, switched off and back on, with a not-ready response:
the warning toast fires in both directions.
- A rejected Patrol mode save shows an error toast with the server
reason, and the mode returns to Watch only. An accepted save switches to
Ask first with no toast.
- Settings > Pulse Intelligence > Patrol and Provider & Models saves
with a not-ready response show a warning toast naming the summary,
provider and model.
- When Patrol status reports not ready, the page's own readiness banner
names the summary, provider and model, and the header shows Fix setup.

Tests: `src/features`, `src/utils` and `src/__tests__` pass, except the
known Mac-only `agentInstallCommand.test.ts`. `tsc --noEmit` passes.
contract, registry and status audits pass.

## Tests and pins

- The hook tests now cover the warning helper.
- The #1789 test pinned `blockedCause: patrolStatus()?.blocked_cause`.
That matched only the removed dead field, not the `blockedCause` memo
the test was written for, so it now pins the memo.
- `frontendResourceTypeBoundaries` pins the new helper name.
- A negative control (mutating both pinned lines) fails all four
affected tests.

## Contracts

`patrol-intelligence`, `frontend-primitives` and `api-contracts` still
described the retired Patrol schedule and model popover, its inline
dialog failure state, and a saved-configuration-issue surface with cause
metadata. They now say:

- where Patrol settings live (Settings > Pulse Intelligence > Patrol,
and Settings > Provider & Models);
- that every save outcome is a toast;
- that the Patrol page's readiness banner carries the not-ready
diagnosis.

The popover rules apply only if a popover returns. The popover-era
alert-trigger clauses are handled separately in
docs/patrol-settings-contract-location.

A second, docs-only commit fixes the patrol-intelligence clause that
still placed the Patrol mode selector in a "configuration dialog". The
selector now renders only in the header's `Mode and automation`
disclosure, and that clause sits directly under #2682's hunk, so it
lands here rather than in docs/patrol-settings-contract-location to
avoid a conflict.
2026-10-07 23:27:33 +01:00
rcourtman 796a70999b Stop tenant incident stores racing on shared ID counters (#2711)
Each tenant monitor owns its own `IncidentStore`, but incident and
incident-event IDs came from package-level `int64` counters bumped with
`++` under nothing but the calling store's mutex. Two tenants recording
alerts at once raced on them. `go test -race` failed
`TestLeavingMockModeClearsEveryRunningTenantMonitor` on #2698
([run](https://github.com/rcourtman/Pulse/actions/runs/37652317098/job/112919080928))
at `incidents.go:1753`. The remediation record counter in
`remediation.go` had the same shape, and `startPatrolForContext` creates
one remediation log per org.

**Fix**
- All six `internal/ai/memory` ID generators (incident, incident event,
remediation record, memory, incident memory, pattern memory) now mint
through one atomic helper, `nextSequencedID` in `ids.go`.
- The numeric suffix no longer wraps at 1000. Even with atomic counters,
a generator that minted more than a thousand IDs in one wall-clock
second repeated an ID. That matters because `RecordNote` finds an
incident by ID and the incident query deduplicates merged events by ID.
The `<prefix><YYYYMMDDhhmmss>-<n>` layout is unchanged. Only `n` can now
exceed three digits.
- `intToString` lost its last runtime callers and is removed.

**Proof**
- `internal/ai/memory/ids_test.go` mints 2,400 IDs from every generator
on 16 goroutines and records alerts into two tenant stores concurrently.
- Against the `origin/main` runtime files (via `-overlay`), `-race`
reports DATA RACE at `incidents.go:1753/1760` and `remediation.go:355`,
and every generator fails the uniqueness/shape check: four produce
duplicate IDs, and `mem`/`pat-mem` produce a wrapped `-0` suffix.
- With the fix, `go test -race ./internal/ai/memory/` passes, and `go
test -race -count=5 -run
TestLeavingMockModeClearsEveryRunningTenantMonitor
./internal/monitoring/` passes.

**Residual:** across restarts, only the timestamp separates IDs. If the
local clock repeats a second (DST fall-back or a backward step), a
restarted sequence could still meet an earlier suffix. Persisted IDs and
the stored format are unchanged. The `ai-runtime` contract records this.
2026-10-07 23:26:15 +01:00
rcourtman 1001eb6a69 Merge remote-tracking branch 'origin/main' into refactor/drop-unread-patrol-state-members 2026-10-07 23:20:49 +01:00
rcourtman df885e1f83 Take main's removal of two frontend-primitives items
Main dropped items 19 and 20 (summary-card emphasis and density-map
overview) in #2709. They sat directly below the FindingsPanel handoff
sentence this branch appends to item 18, so the branch stopped merging.
Take the same deletion here and move the sentence two lines up inside
item 18, so unchanged lines separate it from main's hunk and origin/main
merges cleanly.
2026-10-07 23:18:10 +01:00
rcourtman ad96886eab Remove unused CorrelationsResponse type (#2710)
CorrelationsResponse described the `/api/ai/intelligence/correlations`
payload. #2674 removed its uses in the Patrol investigation context
model and that model's tests, and #2677 removed the last ones
(`AIAPI.getCorrelations` and the aiIntelligence store's correlation
loader). This deletes the now-unreferenced interface from
`frontend-modern/src/types/aiIntelligence.ts`.

`ResourceCorrelation` stays: `ResourceIntelligence.correlations`, the
resource drawer's relationship map (`ResourceCorrelationSummary.tsx`)
and `resourceCorrelationPresentation.ts` still use it.

Proof:
- `git grep CorrelationsResponse` over the whole repository at the base
commit finds only the definition.
- `npx tsc --noEmit` in frontend-modern (tsconfig includes all of `src`,
tests too) passes.
- `vitest run src/types src/api src/stores src/features/patrol
src/utils`: 7295 passed. The only failures are 6 tests in
`agentInstallCommand.test.ts` that need a TTY (`termios.error:
Inappropriate ioctl for device`); they fail identically on the unchanged
base tree.
- `npm run build` before and after emitted the same 187 dist files with
identical SHA-256 sums, so the shipped bundle is byte-identical.
- Browser receipt
`frontend-modern/browser-verification/71a0ce6b24830582.json`: embedded
build in a demo-mode mock stack, `/patrol` and the `/kubernetes`
deployment drawer at 1280x800 and 390x844, no console or page errors.

No contract prose mentions the type, so the commit carries a
`Contract-Neutral:` trailer instead of a patrol-intelligence contract
note.
2026-10-07 23:16:20 +01:00
rcourtman 89a05ed735 Pin each manual-link side from its own identity
A manual link merges two resources for display, and the durable
identity pin was written from that merged identity. A link primary was
pinned with the folded resource's machine key, and the store's
one-owner-per-key rule then deleted the folded resource's own pin.

A Proxmox node's pin records its qualified endpoint as the hostname, so
on the next rebuild the node completed the linked agent's machine key
from that pin, minted the agent's machine-derived ID and merged with
the agent by ID before the link applied. With the node as primary,
succession then rewrote the link into a self-link on the agent's ID; in
either direction, removing the link left the pair merged.

The registry now captures each link side's own pin when the link first
joins it in a rebuild, and PersistIdentityPins writes those instead of
the merged projection, keeping a pinnable folded side under its own
canonical ID. Proxmox auto-links merge by identity before any manual
link applies, so their boot-window pins are unchanged.

A link side rewrites its whole row through a new
ReplaceResourceIdentityPins. Like an upsert it keeps stored fields its
own pin leaves empty, but it drops a strong key another resource holds,
since an earlier release borrowed that key from the merged identity and
the store's one-owner rule would otherwise delete one row or the other.
A folded side drops a strong key a live resource or an earlier folded
side already holds, so the two never trade it on every rebuild, and
writes no pin when that would leave it without its machine evidence.

A node row an earlier release filled with the agent's machine key is
handed back only when the node cannot complete from it. With a
qualified endpoint or a cluster name, completion still supplies the
borrowed key before the link applies, so such a pair stays merged.
2026-10-07 23:14:06 +01:00
rcourtman c5416b6800 Alert on the Unraid temperature an agent disk shows (#2701)
Stacked on #2698 (which sits on #2694, #2685 and #2679); this PR's own
change is the top commit, "Alert on the Unraid temperature an agent disk
shows", plus a merge of main for an alerts.md anchor both touched.

## Why

A host agent's physical disk can show a temperature that only the host's
Unraid inventory reports:

- a SMART row whose smartctl probe returned no temperature takes its
Unraid row's reading (`HostSMARTDiskTemperature`), and
- a disk only the inventory lists, such as a `--disk-exclude` match, is
built from its Unraid row alone (`HostUnraidDiskTemperature`).

Physical Disks (Temp cell and the `Running Hot` verdict, "at or above
its alert threshold") and Patrol judge that reading against the alert
disk temperature policy, but `CheckHost` only read the SMART row's own
field. A disk could read as at its alert threshold while no alert fired.

Unraid's own warning/critical disk temperature notifications do not make
this redundant: Pulse already alerts on the smartctl readings and array
state of the same hosts, which Unraid also notifies on.

## What changes

- `unifiedresources.HostDiskTemperatureReadings(host)` lists the reading
each of a host's disks shows, built with the registry's own adapters:
one per SMART row the registry ingests, then one per Unraid row whose
disk key no SMART row carries (the same rule as the history writer in
#2698).
- `CheckHost` judges those readings:
- SMART rows keep their `agent:<host>/disk_temp:<device>` resource IDs.
An inventory-only disk uses its block device token, which is the ID a
plain Linux SMART row for that device uses, so a later SMART reading
continues the same alert.
- Per-type thresholds and host overrides use the disk type the disk
shows.
- Only a reading collected now fires or resolves, so a spun-down disk
holds its alert.
- When conflicting serials put two disks on one device, its alert judges
the reading that stands highest against its own type's threshold.
- An inventory-only disk matching the agent's `--disk-exclude` raises
nothing, and an alert it raised before the exclusion resolves on the
next report. This matches the documented exclusion from monitoring and
how linked Proxmox hosts already treat excluded disks.
  - Absence cleanup still runs only on a non-empty SMART list.

## Verification

- New alert tests in `internal/alerts/host_unraid_lifecycle_test.go`
cover: the fallback, an inventory-only disk, an empty SMART list, two
disks on one device (also of different types), a controller member under
another device label, standby, exclusion, disk-type and host-override
thresholds, holds, handover to a SMART reading, departure, and the
exclusion clear.
  - Each fails on the previous `host.go`.
- Removing any guard (exclusion skip, exclusion clear, disk type,
collected-now, threshold ranking) fails its own test.
- `TestHostDiskTemperatureReadingsMatchTheDisksTheRegistryShows`
compares every reading with the disk the live registry ingests,
including conflicting serials, a virtual device and a MegaRAID member
Unraid lists under its block device.
- `go test ./internal/alerts/... ./internal/unifiedresources/...` passes
on the merged tree, as do the Unraid/SMART/temperature tests in
`internal/monitoring`.
2026-10-07 23:11:02 +01:00
rcourtman a14584d9f1 Drop the summary focus bridge members nothing reads (#2709)
The jump-to-row affordance lost its only consumer when the summary
components were deleted, yet the bridge kept computing it on every
Workloads and Storage page: a capture-phase window scroll listener and
a resize listener re-measured the hovered or focused row on each frame
and hover change, walking its ancestors with getComputedStyle. On the
12-node mock storage table that was 128 calls across eight row hovers
and 320 across ten scroll steps, now none.

Storage's reveal callback kept branches that switched between the
pools and disks views for that jump. Focus only ever names a row in
the active view, so drop them and the disk-id memo that served them.

The chart-hover input went the same way. No summary chart renders, so
setChartHoverSync was never called and the resolver's chart branch was
always fed null. Remove the signal, its type and the resolver option,
and say in the contract how a returning chart re-enters the shared
scope instead.

Focus reveal, pinned-group reveal, Escape and row emphasis behave as
before at desktop and phone widths; the browser receipt records the
before/after pass.
2026-10-07 23:06:34 +01:00
rcourtman b3b60f0632 Bind SAML logins to the browser that started them
With allowIdpInitiated off, the default, every SAML login failed:
ProcessResponse passed crewjam/saml no request IDs, and crewjam refuses
a Response whose InResponseTo is not among them. Only providers that
allowed IdP-initiated logins, which skips the check, could sign in.

MakeAuthRequest now records each AuthnRequest ID with the login's
returnTo and the hash of a random token that /login sets as the
HttpOnly SameSite=Lax cookie pulse_saml_login (__Host- over HTTPS),
reused across tabs. The IdP posts its Response cross-site, which
withholds Lax cookies, so the ACS answers a cookieless POST with a
page that re-posts the same fields once from Pulse's origin. The
Response must answer an unexpired request bound to the presenting
browser; it spends that request and returns to the recorded path.
Another browser's request is left for its owner.

The request ID is read from the signature covering the assertion
crewjam returns (the signed Response, else that assertion's own
SubjectConfirmationData), not from crewjam's parsed fields and not
from other signed assertions riding in the same unsigned Response.
encoding/xml fills an un-namespaced
attribute field from a namespace declaration with the same local name,
and exclusive canonicalization drops an unused one from the signed
form, so xmlns:InResponseTo could rebind a signed Response and
xmlns:IssueInstant / xmlns:NotOnOrAfter let a stale one pass crewjam's
age checks: an IdP-initiated login could be replayed indefinitely. The
signature verifier now refuses signed content declaring a prefix named
like an attribute crewjam reads, in both modes.

RelayState is now escaped into the redirect query, so a returnTo with
its own query string reaches the IdP whole.
2026-10-07 23:02:13 +01:00
rcourtman e7cabc72f1 Hide pprof routes on demo-mode instances (#2708)
The `/debug/pprof` handlers are gated on `RequireAdmin` plus a
`settings:read` scope that session logins skip. A `DEMO_MODE` instance
that signs visitors in as the configured admin therefore served every
visitor goroutine and heap dumps, the process command line, and CPU
profiles and execution traces that load the whole process. Demo mode
already hides diagnostics, logs, admin users and manual discovery
through the admin-operations policy; pprof was missing from it.

## Change

- `internal/api/demo_mode_operations.go`: through the admin-operations
policy, the demo guard answers `/debug/pprof` and every path below it
with `404` for every method, ahead of route authentication, so a write
probe such as the `POST` symbol lookup gets the same `404` instead of
the generic demo `403`. Instances outside demo mode keep the routes, and
`PULSE_PPROF_DISABLED` still removes them everywhere.
- Contract notes in api-contracts (owner), agent-lifecycle and
storage-recovery.

## Proof

- `TestContract_DemoModeHidesPprofFromDemoAdminSession` signs in as the
demo admin through the full router. The same session gets `200` from
`/debug/pprof/cmdline` below the demo guard, and `404` for eight pprof
paths × `GET`/`HEAD`/`OPTIONS`/`POST` through the served handler.
Against the previous policy (`go test -overlay`) `GET` and `HEAD`
returned pprof output (`200`, or the root's `301` into the index),
`OPTIONS` got the router's preflight `200` and `POST` the generic demo
`403`.
- `TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute` parses the
router files and fails if a literal route under `/debug/`, with or
without a method prefix, escapes the policy for any of seven methods, so
a new debug handler there cannot ship reachable on a demo by default. A
temporary `GET /debug/vars` registration made it fail as expected.
- Path-handling probe (not committed): 210 raw requests (35 request
targets × six methods), covering trailing and double slashes, dot
segments, `%2F`/`%2e` encodings, absolute-form targets and `CONNECT`,
run against a copy of the router's mux dispatch. None reached a pprof
handler; paths the mux would clean onto pprof only receive a redirect,
and the policy catches the follow-up request.
2026-10-07 22:57:17 +01:00
rcourtman 6263c580f7 Judge a linked agent's guest disks by that agent everywhere (#2712)
Follow-up to #2669, which made the guest drawer's filesystem rows follow
a linked Pulse agent's freshness. The workload table's disk cell and the
drawer History still judged the same guest by the Proxmox read reason,
so one guest disagreed with itself across the three.

## What changes

- **One rule for three readers.** `getWorkloadGuestDiskRead`
(`workloadGuestPresentation.ts`) now drives the table disk cell, the
drawer's filesystem rows and History. When the linked agent's
filesystems fill in for a VM that has no Proxmox guest filesystems, the
reading is current while the agent reports, last known once it is stale,
and unavailable once the guest is stopped. Proxmox's own guest
filesystems keep their read reason.
- **Where the aggregate disk metric comes from.** On a manually linked
VM, Proxmox records no guest disk usage without guest filesystems, so
the registry's link merge keeps the agent's disk metric. A registry test
pins that.
- **Poller carry-forward.** `previousVMFromView` built the previous VM
from the merged metric, so the next poll carried the agent's disk
forward as a Proxmox `prev-agent-not-running` read. Proxmox priority
then froze it over the agent's live value. Only Proxmox's own disk
reading is carried now (`VMView.DiskFromLinkedAgent`).
- **`agent.stale` is always on the wire.** The browser merges agent
facets field by field, so an omitted false kept a resumed agent "stopped
reporting" until a reload. This affected Machines rows and node drawers
too.
- **Withdrawn filesystems.** The websocket merge kept filesystems that a
native Proxmox VM facet or a native agent facet (which always carries
`stale`) no longer reported, including in the `platformData.agent`
mirror that host threshold rows read.

## Verification

- New tests, each failing on main: `GuestRow.test.tsx`,
`GuestDrawer.test.tsx`, `workloadGuestPresentation.test.ts`,
`resourceStateAdapters.test.ts`,
`TestPreviousVMFromViewKeepsLinkedAgentDiskOutOfProxmoxCarry`,
`TestAgentDataAlwaysSendsStale`,
`TestVMViewDiskFromLinkedAgentFollowsSelectedMetricSource`, and
`TestResourceRegistry_ManualGuestAgentLinkDiskMetricFollowsProxmoxGuestFilesystems`.
The registry test is a characterization test; it fails when the agent
outranks Proxmox on guest disk.
- Private mock stack at 1440 and 390, with agents linked into VMs
through `POST /api/resources/{vm}/link`:
- Live agent, no QEMU guest agent: before, "- Unavailable · Guest agent
not running", with History deferred; after, the agent's bars, nothing
deferred.
- Silent agent: before, "- Unavailable"; after, bars plus "Last known"
(phone: "Prior"), with History deferred on the agent message.
  - Stopped guest: Unavailable.
  - A guest with Proxmox filesystems: unchanged.
- Machines row for a standalone agent released from a silent hold,
watched without a reload: on main it kept "(stale)"; now it clears.
- Bundle: WorkloadsSurface 61.93 kB gzip against a 70.23 kB cap.

## Known residuals

- Mock mode's websocket state does not apply manual links to resources
ingested as records (`ingestRecords` never runs `applyManualLinks`), so
the browser pass ran the Proxmox page on REST data. In production this
likely affects links to vSphere or TrueNAS resources; it is tracked
separately.
- A stale Proxmox source can lose the aggregate to a live agent while
its own filesystem rows and `prev-` reason remain. That guest stays on
the Proxmox rule and reads its live aggregate as last known, which is
the same as main and errs toward caution. A guest with no platform read
reason can likewise keep a retained aggregate after the agent withdraws
its filesystems, also as on main. Both need aggregate provenance on the
wire.
2026-10-07 22:55:02 +01:00
rcourtman 15322e1573 Drop contract prose for the deleted problem-resource surfaces (#2706)
Contract prose still described surfaces that no longer exist:

- `frontend-primitives.md` told new route-level copy to extend
`problemResourcePresentation` (deleted in ae65c5e9a2), tied that helper
to `ProblemResourcesTable.tsx` (deleted with the dashboard overview in
c743a231b8), and kept a readability rule for that table and the
dashboard Pulse Brief. No live surface implements the rule; Patrol's
grouping and affected-resource counts are governed in
`patrol-intelligence.md`. The ownership paragraph and the rule are
dropped, and problem-resource joins the list of deleted dashboard
helpers that must not return.
- `unified-resources.md` said the change and correlation summary cards
default Infrastructure resource-filter links for the Patrol page and the
problem-resource dashboard panels. Since the 2026-05-16 drilldown
retirement the cards have no link default and render only in the
resource drawer, which passes no link builder.
- `api-contracts.md` named the dashboard Pulse Brief as a sender of
`autonomous_mode:false` and described the field as a client override.
Since 4edf5f8265, `ChatRequest` has no such field: the server runs every
`/api/ai/chat` exchange in approval-required mode (pinned by
`TestHandleChat_CannotElevateAutonomousModePerRequest`), and the field
is dropped at decode.

The new `subsystem_lookup_test.py` case pins the deleted helper as
unowned, next to the other retired dashboard paths.

Verification: `contract_audit.py`, `registry_audit.py` and
`status_audit.py` pass with `--check`; the retired-path lookup tests
pass (9/9); the full pre-commit battery passed. No frontend or backend
runtime files change.
2026-10-07 22:44:27 +01:00
rcourtman 23bf99e1ba Keep alert settings a config save leaves out (#2689)
## Problem

Saving anything on Alerts > Thresholds turned flapping detection and
alert TTL cleanup off. The page's `PUT /api/alerts/config` body never
carries `flappingEnabled`, `maxAlertAgeDays`, `maxAcknowledgedAgeDays`,
`autoAcknowledgeAfterHours` or `customRules` (the page has no control
for any of them). `UpdateAlertConfig` decoded the body into a zero
`AlertConfig`, so every save wrote `false`/`0` for those fields, reset
the flapping tuning to defaults, dropped custom rules, and persisted
that. The `flapping`, `aggregation` and `ioNormalization` objects the
page sends are not `AlertConfig` fields and never enabled anything.

## Fix

- `PUT /api/alerts/config` now replaces only the top-level keys the body
carries. A key left out keeps its stored value. A sent key replaces its
setting whole, so `"overrides": {}` still clears overrides, and the
usual normalization still runs. A body that is not a JSON object
(including `null`) is a 400 and changes nothing.
- `alertconfig.ApplyAlertConfigUpdate` decodes the body on its own, so
sent keys get exact `encoding/json` semantics, including duplicates and
case-folded keys. It copies every unsent top-level field from a JSON
clone of the stored config.
- `Manager.ApplyConfigUpdate` reads, merges and applies under the
manager lock, so concurrent partial saves cannot revert each other. It
returns an owned snapshot, which the handler persists, so
`SaveAlertConfig`'s in-place normalization no longer writes into the
live config's maps. The handler runs one save at a time, so successful
saves reach `alerts.json` in the order they were applied.
- Stored configs are not rewritten; a value an earlier save zeroed stays
as it is.

## Verification

- Unit: `internal/alerts/config/update_test.go`; manager save path and
concurrent partial updates in
`internal/alerts/config_validation_test.go`; handler against a real
manager in `internal/api/alerting/alerts_test.go`. Each new regression
fails against main's code (overlay negative controls). The concurrency
test fails at round 0 against a non-atomic variant.
- `go test ./internal/alerts/... ./internal/api/alerting/` and the
`internal/api` alert endpoint, tenant and scope tests pass; the alert
tests pass under `-race`.
- Private stack (mock mode, Vite on main's frontend):
- On main, a node CPU change saved from the Thresholds page took
flapping and all three TTLs to false/0, in memory and in `alerts.json`.
- With this change, the same UI save kept them (true/7/1/24) and stored
the new threshold.
- The API repro (GET, drop the four keys, PUT) keeps them; explicit
`false`/`0` still applies; `null` and `[]` return 400.
- Five rounds of concurrent partial PUTs kept both changes in memory and
on disk.

## Follow-ups

- The thresholds page still sends the dead
`flapping`/`aggregation`/`ioNormalization` objects and hard-coded
`minimumDelta`/`suppressionWindow`/`hysteresisMargin`. A frontend
cleanup will follow once the open PRs touching
`alertsConfigurationModel.ts` land.
- `SaveAlertConfig` still normalizes caller-owned maps in place for
other callers (`ActivateAlerts`, startup paths), and the GET handler
encodes a shallow copy. That pre-existing race is tracked separately.
2026-10-07 22:23:41 +01:00
rcourtman 36d3f8c557 Judge pulse-relaxed guests by one threshold on poll and save (#2705)
Stacked on #2700 (which carries #2696 and #2692). Merge order is
enforced by the stack: this change must not reach main before #2700's
per-filesystem reevaluation (see "Why stacked" below).

## Problem

`pulse-relaxed` lifts a guest's CPU, memory and disk triggers to a
95/92/95 floor. Only `CheckGuest` applied it. Config-save reevaluation
rebuilt the guest from alert metadata without its tags
(`guestSnapshotFromAlert`), so a save judged a relaxed guest's alert by
its unrelaxed thresholds:

- A relaxed guest with an unset memory threshold alerts at 92 while
polling. On save that reads as no threshold, so the save resolved the
alert and sent a recovery notification, and the next poll raised it
again.
- The floor also lifted an Off threshold (trigger 0, or -1 from the
editors) like any low trigger, so tagging a guest relaxed turned an Off
alert on, with no recovery band (`{95, 0}`). A save then re-judged it
Off and resolved it, and the next poll raised it again.

## Change

- The relaxed step moves into `effectiveAlertPolicyNoLock`'s guest
branch, so `CheckGuest`, config-save reevaluation (including #2700's
`resolveGuestAlertThresholdsNoLock`) and posture checks share one
resolver.
- `guestSnapshotFromAlert` reads the recorded `tags` back, as `[]string`
live or `[]interface{}` after a restart, through a new
`metadataStringsValue` reader. `storagePolicyAliases` now uses the same
reader.
- An Off threshold (trigger <= 0) stays Off under `pulse-relaxed`; an
unset one still gets the floor, as the existing tests pin. The tag is
documented (issue #863) as relaxing thresholds, so it must never make a
guest more sensitive than its config.
- `metricOptionsWithTags` drops the `tags` key from an open alert once
its resource has no tags. Reevaluation now reads that key, so a removed
`pulse-relaxed` left behind would judge the alert by the relaxed trigger
while polling uses the guest's own, and the alert would flap.
Notification tag routing reads the same key, so it also stops routing on
removed tags.

## Why stacked

A filesystem's own override replaces the relaxed guest disk threshold in
`CheckGuest`. On main, reevaluation misses filesystem overrides (fixed
by #2700). With this change alone, a save would judge a relaxed guest's
filesystem alert by the 95 floor instead of, say, an 85 override, and
flap it. `TestConfigSaveKeepsRelaxedGuestFilesystemOverride` fails on
main plus this change and passes on this stack.

## Proof

New tests in `internal/alerts/alerts_test.go` and
`internal/alerts/unified_eval_test.go`. With `go test -overlay` mapping
the six runtime files back to #2700's head, these fail:

- `TestConfigSaveJudgesRelaxedGuestAlertByRelaxedThresholds` (the save
resolved the alert)
- `TestPulseRelaxedKeepsOffGuestThresholdOff` (a disk alert was raised
with disk Off)
- `TestApplyRelaxedGuestThresholds/off_thresholds_stay_off` (Off became
`{95, 0}` / `{92, 0}`)
- `TestCheckGuestDropsRemovedTagsFromOpenAlerts` (stale tags; with only
the metadata assertion relaxed, the save resolves the alert)

`go test ./internal/alerts/... ./internal/notifications/` and the
monitoring alert tests pass.
2026-10-07 21:59:48 +01:00
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