Commit Graph

6040 Commits

Author SHA1 Message Date
rcourtman e02edd9989 Show retained disk temperatures as last known in drawers and tables
Normalization keeps a disk's last temperature when the current
observation did not collect one, such as a disk in standby or a host
agent past its reporting lease. PR #2603 taught the Storage presenters
to show that value as last known. Four surfaces still read the raw
number as a current reading:

- The TrueNAS disk drawer's Temperature row took the 55C warning tone
  from a retained reading, and its summary listed it as a reading.
  The row now says "(last known)" with the reason as title and no
  tone. The summary drops it because it has no room for the reason.
- The drawer History fallback fed a retained reading into smart_temp
  and labelled it "current" when no points were stored. It now goes
  through the deferred-metric slot, so the legend says "last known" and
  the reason shows above the chart. The disk's value is read from
  physicalDisk beside its collection state, because websocket rows
  carry no top-level temperature for a disk and showed "-" instead.
- The TrueNAS storage table's Temp cell showed a retained reading
  plainly, and hottest-first sorted it to the top. It now renders muted
  with its reason, and the sort ranks it as no reading.
- The Proxmox outdated sensor setup notice counted a retained reading
  as a temperature arriving, so it could hide for a SATA disk Pulse
  cannot read now.

All four reuse getPhysicalDiskTemperaturePresentation or
isPhysicalDiskTemperatureCurrent instead of adding a predicate.
2026-10-07 12:29:01 +01:00
rcourtman 41cea2ffab Stop a replaced PVE disk inheriting the old disk's evidence (#2648)
## Problem

The Proxmox disk poller keeps evidence it could not collect this poll by
looking up the previous poll's record of the disk: by serial, then WWN,
then the Proxmox source ID (`instance-node-devpath`), then the device
token. The source ID and device token name a slot, not a disk, and a
replacement in the same slot keeps both. So:

- a replacement reporting a different serial or WWN took the old disk's
retained temperature, controller, pool and I/O;
- a replacement record with an empty serial also took the old disk's
serial. The registry keys disks on serial, so the new disk became the
old disk's canonical resource and SMART metric series, and every later
poll carried the borrowed serial forward.

Current Proxmox reports a missing serial as the literal `unknown`, which
the poller records as reported, so the serial copy needs a producer that
sends an empty serial, such as the host-agent fallback rows. The
evidence copy happens with real Proxmox data.

## Change

- `previousPhysicalDiskEvidence` matches by hardware identity through
`diskinventory.HardwareIdentityMatch` (serial first, then WWN, each
against both fields of the earlier record), and refuses a slot match
when both records carry a serial or WWN and none agrees. Missing
identity is still not a conflict: placeholders such as `unknown`, and an
empty serial, keep the slot's evidence as before.
- The registry's merged disk view keeps the host agent's WWN, which the
agent spells as unpadded NAA 5 fields (`5-c50-a1b2c3d4`,
`internal/hostagent/smartctl.go` `formatWWN`) while Proxmox reports
`0x5000c500a1b2c3d4`. `HardwareIdentityMatch` now re-pads that spelling
for WWN fields only, so one disk seen by both reporters is not taken for
a replacement. The registry's linked-disk join uses the same comparison.

## Tests

- `TestPhysicalDiskReplacementInSameSlotDoesNotInheritPreviousSerial`
drives the poller and registry across two polls after a swap; on base
the replacement inherits the old serial and canonical resource. Its
controls keep a serial-omitting same disk on its resource.
-
`TestPreviousPhysicalDiskEvidenceRejectsSlotMatchAcrossConflictingIdentity`
covers renamed devices matched only by identity, PVE vs agent WWN
spellings, SAS address serials with a matching WWN, placeholders, and
conflicting serial/WWN by source ID and by device token.
- Negative controls via `go test -overlay`: on base the replacement,
conflict and renamed-device cases fail; with the guard but without the
WWN re-padding, the same-disk PVE/agent WWN cases fail.
- `pkg/diskinventory`, `internal/unifiedresources` and
`internal/monitoring` pass in full.

Follow-ups, not in this change: the host-agent SMART merge's device-path
fallback can still fill or promote a serial from a stale SMART row; QEMU
default serials (`drive-scsi0`) collapse nested Proxmox nodes' disks
into one resource; and the registry merges disks across parents on a
canonical-ID collision.
2026-10-07 12:12:23 +01:00
rcourtman d5c945241d Show a remote unacknowledge in a tab that acknowledged locally (#2647)
After acknowledging an alert on Alerts > Overview, an unacknowledge from
another session never brought the card back in that tab until a reload,
although `/api/alerts/active` and the store both had it unacknowledged.
A pure remote acknowledge/unacknowledge round trip (no click in the tab)
already worked.

## Cause

`useAlertAcknowledgementState` kept its own acknowledgement override and
spread it over the store alerts. Its release effect never read the
override signal and only re-ran when the `Object.values` memo over the
keyed alert store changed, which in-place store writes (`updateAlert`,
`reconcile` payloads) do not trigger. The override outlived the server's
confirmation and then masked the server's next change. Whether it
cleared depended on unrelated alerts appearing or resolving, so the bug
was intermittent.

The override was redundant: the websocket store's `updateAlert` already
writes the optimistic state and holds back stale payloads until the
server confirms. The hook now writes through it and keeps no copy.
`updateAlert` is required, and its only caller always passed it.

## Store hold fixes (`frontend-modern/src/stores/websocket.ts`)

- **Broadcast before response:** the backend broadcasts after writing
the HTTP response, so the confirming payload can reach the socket before
the response resolves. The hold then waited for a second confirmation,
skipped a following remote unacknowledge, and after 15s showed "Server
did not confirm the alert acknowledgment in time" without actually
re-syncing. Reproduced live. A change the stored alert already shows now
sets no hold and keeps the server's `ackTime`/`ackUser`.
- **Give-up re-sync:** a hold that gives up now re-syncs from
`/api/alerts/active`. If it joins a recovery that the revision fence
then discards, it fetches again, since a quiet estate may send no
further alert delta.
- **Overlapping REST recovery:** a local acknowledgement write
supersedes a REST recovery already in flight, whose response may predate
the write. The store discards that response and fetches again once it
settles.

## Tests

- **Hook (`useAlertAcknowledgementState.test.tsx`), over a real
`createStore` + `reconcile` store:**
  - local acknowledge, then remote unacknowledge
  - local restore, then remote acknowledge
  - acknowledge, restore and bulk acknowledge

  The first two fail on the old hook at the remote-change step.
- **Overview state (`useAlertOverviewState.test.tsx`):** now backed by a
keyed store, and checks that the card returns after a remote
unacknowledge. Fails on the old hook.
- **Store (`websocket-unified.test.ts`):** covers the
broadcast-before-response race, the give-up re-sync, the
overlapping-recovery supersede, a give-up joining a fenced-out recovery,
and a recovery that predates a held acknowledge. The first two fail on
the old store. For the recovery races, removing each mechanism
(supersede, its fresh fetch, the re-sync chain) fails the test that pins
it.

## Browser check

Private mock stack at 1280×900 and 375×812:

- **Before:** the card stayed hidden while the header read "2 critical,
Acknowledged 1" against a nav badge of 3 critical.
- **Reported flow:** a local Acknowledge, then a `curl` unacknowledge,
brings the card back within 2s.
- **Restore direction:** Unacknowledge in the tab, then a remote
acknowledge, moves it back to Acknowledged within 2s.
- **Race:** with the acknowledge response held 1.5s so the socket wins,
the card returns at once with no toast. With only `websocket.ts`
reverted to main, it stayed hidden about 16s and then the toast fired.

## Left as is

If the give-up's own REST fetch fails, the tab keeps the local state
until the next alert payload. That matches main, where a give-up never
re-synced at all.
2026-10-07 11:58:18 +01:00
rcourtman 044dc091ec Own PVE disk alert history rows by recorded hardware (#2646)
Follows #2627, which left PVE disk alert references out of resource
history: binding the path reference failed review because a binding
belongs to the reference string, so any disk that takes over the path
inherits every row and every read of it. A physical disk's history
(facets, timeline and intelligence APIs, Assistant resource context,
Patrol's scoped feed) therefore never showed its own SMART health and
wearout alerts.

This owns each lifecycle row instead of binding the reference.

**Write**
- `proxmoxDiskAlertMetadata` now records `disk_wwn` next to
`disk_serial`.
- `MonitorAdapter.RecordChange` writes a PVE disk alert row under the
one physical disk the registry knows by that serial or WWN
(`proxmoxDiskAlertOwner`), wherever it sits now. A WWN only breaks a tie
between two matches; it never rules a disk out, because the registry
keeps a merged agent's smartctl-framed WWN (`5-c500-…`), which doesn't
compare equal to PVE's (`0x5000c500…`). The registry only translates
identity into a canonical ID. The path never breaks a tie, so a
generation that still places another disk at the path cannot gain a row
that disk does not match by identity, and no check precedes a later
write. A disk not yet in inventory, or removed, gets the ID the registry
mints for its identity unless another resource holds it. A row with no
usable identity names only the one identity-less disk at its path.
Anything else stays under the reference.
- An owned row records the alert's reference as `alert_resource_id`. No
alias is written, the reference is never queued for the legacy backfill,
and `resolveHistoryReference` refuses it.
- Rows journaled before this change stay under the reference,
unrewritten (journal rows are never rewritten; retention is 30 days).

**Read**
- A store read by the reference (rows and every count, SQLite and
MemoryStore) also returns the rows owned away from it. They are matched
through the indexed alert identifier plus the recorded reference, so the
Alerts history Resource action lists the same occurrences as before.
- `QueryIncidents` matches owned rows to occurrences under their alert
reference, while their evidence keeps the owning disk. Reads by alert
identifier and by the reference return the same occurrences as before,
including one occurrence whose firing and resolution two disks own. A
read by a disk admits the saved shells of its own occurrences. An owned
row whose firing the read lacks takes the occurrence or saved shell at
its recorded start (shells within the usual tolerance), or opens it
under the alert-centric read's ID, before events are assigned. That
boundary is not canonical, so capped reads keep their guard. Notes
recorded from a disk read reach the occurrence the alert-centric read
shows.

**Known residuals (in the unified-resources contract)**
- History follows inventory where inventory merges: same-serial disks, a
serial carried at a path when a poll reports none, and disks correlated
with an agent observation by path.
- When PVE and the agent share no identifier for such a disk (a SAS
address reported as the serial), or the alert fired before the PVE
observation reached the registry, the row carries the minted ID. The
disk's history omits it, and reads by the reference still return it.
- A disk the registry keys by its agent's WWN alone also carries PVE's
serial, so a different disk reporting that same serial, before its first
poll reaches the registry, has its row written under the first disk.
- Two occurrences of one alert starting within the shell tolerance: the
alert-centric read assigns a saved shell by firing order, which a disk
read missing a firing cannot reproduce.

**Tests**
- `TestProxmoxDiskAlertRowsFollowRecordedHardwareIdentity` (SQLite and
memory): reboot reorder before the registry catches up, replacement
before and after its first poll, WWN-only, path-only, placeholder
serial, counts.
- `TestProxmoxDiskAlertOwnerDecisions`: shared serials, WWN tie-break,
agent-framed WWN, RAID volume (full NAA serial, truncated WWN), minted
ID already held, identity-less paths.
- `TestHistoryIdentityNeverBindsProxmoxDiskAlertReferences`, which
replaces `TestHistoryIdentityLeavesProxmoxDiskAlertReferencesUnbound`.
- `TestProxmoxDiskAlertReferenceReadUsesIndexes`: the read by reference
uses the canonical and alert-identifier indexes, not a scan.
- `TestIncidentQueryKeepsOccurrencesOfHardwareOwnedDiskAlerts` compares
the journaled and owned layouts for alert-identifier, `StartedAt` and
reference reads, plus disk reads, notes, evidence and a split occurrence
with no shell, an exact shell and an offset shell, on both stores.
`TestIncidentQueryTruncatedReadKeepsOwnedOccurrencesGuarded` covers
capped reads.
- `TestCheckDiskHealthAlertsCarryHistoryOwnershipIdentity`: the
producer's alert identifiers match
`ProxmoxPhysicalDiskAlertIdentifiers`.
- `TestProxmoxDiskAlertTimelineFollowsRecordedHardware`: real alert
manager, reboot reorder, incidents by reference and by disk.
- Each new test fails against the code it guards (checked by reverting
it).

**Live check** (private mock-mode build, fresh data dir): 6 fired rows
and 1 acknowledgement, all owned by the disk their serial names. No
aliases. Each disk's timeline API returns its rows.
`/api/ai/incidents?resource_id=<reference>` and `?resource_id=<disk>`
return the same incident (same saved ID; fired and acknowledged events
whose evidence names the disk).
2026-10-07 11:53:57 +01:00
rcourtman 853e172fc2 Pair a merged disk's temperature with its rows' own states (#2645)
Follow-up to #2618.

## Why

The registry merges a physical disk's rows (Proxmox disk, host agent
SMART, Unraid inventory, TrueNAS) one step at a time. It chooses the
temperature by source preference and merges collection states
separately. #2618 paired the shown value with its row's state, but only
exactly for two rows: with three or more, an earlier step's single
merged state stood in for its rows. Two counterexamples (same serial, no
SMART attributes on the agent row):

1. Silent agent SMART 72 (`unavailable`, smartctl), offline Unraid
inventory 41 (`unavailable`, unraid), stale Proxmox copy 41
(`available`, smartctl): the merged disk showed **41 as collected**.
2. A legacy agent's fresh 50 (no state) next to a Proxmox inventory row
(`unsupported`) showed **50 as unsupported**. With a second Proxmox row
collecting 40, which state the 50 showed depended on ingest order.

The end-to-end test added here also shows a production case on main
today. A pre-6.2 agent linked to a PVE node whose host goes down
withdraws its reading without a source. Its copy on the Proxmox disk
carries `host_agent`, and no disk poll refreshes that copy. Through the
production rebuild (`PopulateFromSnapshot`), the canonical disk then
shows the retained 38 as `available(host_agent)`. With equal values,
#2618's pairing skipped, and `MergeReportedStatus` could not match `""`
to `host_agent`.

## What

- The merged disk keeps each row's current temperature reading and
state. This is merge-time only, never serialized, and a row that reports
again replaces its reading.
- The shown value's state comes from the readings of that value. A
collected reading wins over one that isn't, and the agent's own report
wins over a copy. While a current reading holds the shown value, the
order in which different rows arrive no longer changes the state.
- A state another row copied from an agent source gives way to the
agent's own withdrawal of that source, in either order. That covers a
copied `available` and a copied older withdrawal.
- A legacy agent's sourceless withdrawal now matches its `host_agent`
copy. The legacy-agent rule moves into
`diskinventory.LegacyHostAgentStatus`: monitoring stamps the Proxmox
copy with it, and the registry matches the withdrawal with it, so the
two cannot drift.
- A second agent reporting the same disk is not a copy, so its
withdrawal no longer withdraws another agent's own collected reading.
Before this change, the result depended on ingest order.
- When source preference keeps a value that no current reading holds,
the value keeps the state it was presented with and the row that state
belonged to. That happens when its row went into standby, or reported
again without the SMART attributes that made it preferred. The value
takes that row's later withdrawal, and the agent's withdrawal of its
source still supersedes it.
- Value selection is unchanged. A silent agent's 72 next to Proxmox's
collected 40 still shows 72 as last-known, as the existing test pins.
- Boundaries:
  - A disk entering as a new row is that row alone.
- A disk seeded from a clone continues from its readings only while it
still presents the value and state they were built for.
- Otherwise it stands as one reading of unknown rows. An agent's
withdrawal of its source supersedes that reading, and the reading still
withdraws for the agent when the agent is among its sources.

## Known limits

- JSON keeps only the presented state. A withdrawal the state does not
show is lost across a round trip, but the production rebuild never
merges across JSON.
- A manual link keeps the primary disk's readings and drops the other
disk's.
- A kept value's state depends on the history of reports, as the kept
value itself does. It stays `available` when its row now reports a
different collected value (same as before). Kept values need repeated
reports from one row, so they arise only in incremental ingest, never in
the per-poll rebuild.

## Tests

- `TestPhysicalDiskMergePairsEveryShownTemperatureWithItsRowState`:
every ingest order of a catalog of 8 agent, 3 Unraid (plus none), 7
Proxmox and 1 TrueNAS (plus none) row variants. It checks the shown
state against a set-based oracle, including the collected-first and
own-report-first preference.
- `TestPhysicalDiskMergeNeverPresentsAWithdrawnTemperatureAsCollected`
now covers counterexample 1 with equal values in all orders.
`TestPhysicalDiskMergeKeepsALegacyAgentReadingCollected` covers
counterexample 2.
- `TestPhysicalDiskMergePairsTemperatureWithItsCollectionState` gains
two cases: equal-value resumption against a Proxmox copy that has SMART
attributes, and a legacy agent past its lease.
- `TestPhysicalDiskMergeTemperatureReadingsAcrossIngestBoundaries`
covers:
- clones that are seeded back, edited, or re-ingested as a Proxmox row;
  - presented disks against newer agent reports;
  - same-row re-reports and kept values;
  - two agents sharing a source.

Against `origin/main`'s registry, every case passes except four this
change improves: clone continuation, a kept value from another source,
and the two two-agent cases.
- `TestSilentLegacyAgentCanonicalDiskFollowsItsWithdrawal` (monitoring,
end to end through `PopulateFromSnapshot`): a legacy agent's disk is
collected while it reports, not collected once its lease expires with a
stale Proxmox copy, and collected again when it resumes. It fails on
main.
- `TestLegacyHostAgentStatusFillsTheLegacyAgentProvenance`
(diskinventory) covers the shared helper.
- The existing pairing, withdrawal, Unraid masking and
`physical_disk_roundtrip_test.go` tests stay green.
- A sweep of 22 code mutations is caught by these tests. It covers
dropped supersession, preference order, stand-in flags, the legacy key,
record validation, stamping, kept-value handling and owner tracking.

Contracts updated: unified-resources (merge rule and its limits),
monitoring and agent-lifecycle (legacy withdrawal matching),
storage-recovery (what storage surfaces read from a merged disk).
2026-10-07 11:53:48 +01:00
rcourtman 9b644b3d49 Stop a replaced PVE disk inheriting the old disk's evidence
The disk poller keeps evidence it could not collect this poll by looking
up the previous record, first by serial and WWN, then by the Proxmox
source ID and device token. Those last two name a slot, not a disk, and
a replacement in the same slot keeps both. So a replacement reporting a
different serial or WWN took the old disk's retained temperature,
controller, pool and I/O. A replacement record with an empty serial
also took the old serial. The registry keys disks on serial, so the
new disk became the old disk's canonical resource and SMART metric
series, and each later poll carried the borrowed serial forward.

Match on hardware identity through diskinventory.HardwareIdentityMatch,
serial first then WWN, and refuse a slot match when both records carry
a serial or WWN that share no value. Missing identity is still not a
conflict, so Proxmox's literal "unknown" and other placeholders keep
the slot's evidence as before.

The registry's merged disk view keeps the host agent's WWN, which the
agent spells as unpadded NAA 5 fields ("5-c50-a1b2c3d4") while Proxmox
reports "0x5000c500a1b2c3d4". HardwareIdentityMatch now re-pads that
spelling for WWN fields, so the same disk seen by both reporters is
not mistaken for a replacement.
2026-10-07 11:23:34 +01:00
rcourtman df9514df37 Show a remote unacknowledge in a tab that acknowledged locally
After acknowledging an alert on Alerts > Overview, an unacknowledge from
another session never brought the card back in that tab until a reload,
although /api/alerts/active and the store both had it unacknowledged.

useAlertAcknowledgementState kept its own acknowledgement override over
the store alerts. Its release effect never read the override signal and
only re-ran when the Object.values memo over the keyed store changed,
which in-place store writes do not trigger. The override outlived the
server's confirmation and then masked the server's next change; whether
it cleared depended on unrelated alerts appearing or resolving. The
websocket store's updateAlert already writes the optimistic state and
holds back stale payloads until the server confirms, so the hook now
writes through it and keeps no copy (its only caller always passed it).

The store hold had gaps of its own:

- The backend broadcasts after writing the HTTP response, so the
  confirming payload can arrive before the response resolves. The hold
  then waited for a second confirmation, skipped a following remote
  unacknowledge, and after 15s showed "Server did not confirm the alert
  acknowledgment in time" without re-syncing. A change the stored alert
  already shows now sets no hold and keeps the server's ackTime/ackUser.
- A hold that gives up now re-syncs from /api/alerts/active, fetching
  again if a recovery it joins is fenced out, since a quiet estate may
  send no further alert delta.
- A local acknowledgement write supersedes a REST recovery in flight,
  whose response may predate the write: the store discards it and
  fetches again once it settles.

Tests: hook tests over a real createStore/reconcile store (local
acknowledge then remote unacknowledge, local restore then remote
acknowledge, bulk) and the overview-state test backed by a keyed store
fail on the old hook at the remote-change step. The store tests for the
broadcast-before-response race and the give-up fail on the old store;
the recovery races are each pinned by a test that fails when its
mechanism is removed.

Browser check on a private mock stack at 1280x900 and 375x812: a local
acknowledge then a curl unacknowledge brings the card back within 2s
(before, it stayed hidden while the nav badge already counted it); a
local restore then a remote acknowledge moves it back to Acknowledged;
with the acknowledge response delayed 1.5s so the socket wins, the card
returns at once with no toast, where main's store held it back for
about 16s and then showed the toast.
2026-10-07 11:19:25 +01:00
rcourtman 1f2ff618b6 Own PVE disk alert history rows by recorded hardware
PVE disk health and wearout alerts reference the disk by instance, node
and device path, so a physical disk's history never showed its own SMART
and wearout alerts. Binding the path reference failed review: a binding
belongs to the reference string, so any disk that takes over the path
inherits every row and every read of it.

Own each lifecycle row instead. The alert now records the disk's WWN
next to its serial, and the history writer puts the row under the disk
the registry knows by that hardware identity, wherever it sits now. The
registry only translates identity into a canonical ID, so a generation
that still places another disk at the path cannot claim the row, and no
check precedes a later write. A disk not yet in inventory, or removed,
gets the ID the registry mints for its identity. Rows without usable or
unambiguous identity stay under the reference, as do rows journaled
before this change; journal rows are never rewritten.

Reads by the reference still return every row raised under it, through
the alert identifier index, and the incident projection keeps owned rows
under their alert reference, so the Alerts history Resource action and
incident timelines show the same occurrences as before. A read by the
disk returns its own occurrences with their saved shells and notes.
2026-10-07 11:18:45 +01:00
Richard Courtman ff67e38968 Pair a merged disk's temperature with its rows' own states
The registry merges a physical disk's rows one step at a time, choosing
the temperature by source preference and merging collection states
separately. The pairing rule from #2618 was exact for two rows only: with
three or more, an earlier step's single merged state stood in for its
rows. A silent agent, an offline Unraid inventory and a stale Proxmox
copy could leave a withdrawn 41 labelled collected, and a legacy agent's
stateless reading could pick up another row's "unsupported".

The merged disk now keeps each row's current reading and state
(merge-time only, never serialized), and the shown value takes its state
from the readings of that value: a collected reading first, then the
agent's own report over a copy. A copy of an agent source's state gives
way to the agent's own withdrawal, including a legacy agent's sourceless
one, and a value source preference keeps after its row reported again
keeps its presented state under that row's later withdrawal. Value
selection is unchanged.
2026-10-07 11:18:10 +01:00
rcourtman 812c712abb Merge remote-tracking branch 'origin/main' into fix/stale-agent-disks-card 2026-10-07 11:15:13 +01:00
rcourtman 8b36dec0a2 Keep a source's own offline verdict through the stale pass (#2643)
## Problem

The unified registry's stale pass recomputed a resource's status from
its source sightings, and read a sighting that had *delivered recently*
as *online*. A sighting only says the source delivered, not what it
reported:

- A Proxmox node the cluster reports offline on a live poll came back
`online` once its linked host agent went silent, which is exactly what a
powered-off machine's agent does (the #1441 / discussion #1135 symptom
for users with an agent on the node). The Proxmox nodes table showed an
amber dot next to its own "Offline" label while the drawer led with a
critical "Node is offline" alert.
- A node `preserveOrExpireNodes` expired after failed polls read
`warning` ("Degraded"), and the stopped guests on it were counted under
"Attention".

Node alerts were not affected: `CheckNode` reads the poller's own
`models.Node`. Everything that reads the projection was
(`/api/resources`, websocket state, `NodesSnapshot`, Patrol and AI
tools, connections grouping, reporting, charts).

## Fix

Each sighting records the status its source reported (an unexported
verdict that replaces the narrower lease marker from #2634), and
`aggregateStatus` reads it:

- While any source with a verdict is current, only the current sources
decide, in the priority order the merge uses (agent, then Proxmox or
vSphere, then Docker), so a live agent still keeps a node online when
the Proxmox API path to it fails.
- Once every source is quiet, an `offline` verdict survives and anything
else reads `warning`, as before.
- Sightings still report delivery, so health keeps its `telemetry_stale`
reason.

Availability checks projected onto a target keep counting by delivery,
exactly as on main. Giving them a verdict needs each check's own
freshness, and that is left to a follow-up (documented in
unified-resources.md).

## Verification

- Registry tests in `registry_merge_policy_test.go`: live poll reports
offline with a silent agent (inside and past its lease), poller-expired
node alone and with its agent, stopped and running guests on a quiet
poll, JSON round trip, in-memory copy keeping a facet without a verdict,
and an aggregation table. The node, poller-expired, guest and round-trip
tests fail on main with the bug's exact symptoms; all of #2634's lease
tests still pass.
- Private mock stack (demo estate, one node held offline on a fresh poll
with its agent silent): before, both offline nodes showed amber
("Offline"/"Degraded" pills) and 44 stopped guests counted as Attention;
after, both read Offline and the guests moved to Stopped. A full status
diff of ~1,780 resources showed no other systematic change.
- `go test` for unifiedresources, monitoring, ai, alerts, mock, api,
api/resourceapi and api/chartapi passes.
2026-10-07 10:56:28 +01:00
rcourtman 8ad0cbbe26 Move static inline styles to classes the production CSP allows (#2642)
## Why

The production server sends `style-src 'self' 'nonce-...'` with no
`'unsafe-inline'` (`internal/api/security.go`). Solid compiles static
style values, such as `style="..."` or a literal entry of a `style={{
... }}` object, into template HTML. The browser reports each one as a
`style-src-attr` violation. Opening a row's inline detail on Machines or
Docker Images logged eight of them. The HelpIcon popover, the Assistant
typing dots and the alert threshold slider logged their own.

Chromium still applied the styles after Solid cloned the template, so
the visible effect there was console noise. Whether a stricter browser
applies them is not guaranteed. The Vite dev server allows inline
styles, so none of this showed up in development.

## What changed

- 24 drawer tab-panel `overflow-anchor` styles (ResourceDetailDrawer,
GuestDrawer, NodeDrawer, DockerHostDrawer, StoragePoolDetail,
DiskDetail) became the `[overflow-anchor:none]` class.
- The Assistant typing-dot delays became `[animation-delay:...]`
classes. `animate-bounce` already sets the 1s duration.
- ThresholdSlider uses `touch-none`.
- The HelpIcon popover keeps its dynamic position in `style` and takes
`min-w-[200px]` as a class.
- The boot-failure fallback in `index.tsx` is built from DOM nodes and
classes, which also stops the error text being parsed as markup.
- A new guard in `SharedPrimitives.guardrails.test.ts` compiles every
runtime `.tsx` that mentions `style` with the client-build Solid preset.
It fails on any `style` attribute or `<style>` element in the resulting
templates, nested `<template>` content included. The regex scans I used
first missed a mixed style object in HelpIcon; this check caught it.
- A note in `frontend-primitives.md` records the rule.

## Verification

- Production build of this branch embedded in a mock-mode backend that
serves the real nonce CSP, compared with an origin/main build on the
same setup. Checked at 1440x900 and 390x844, plus 1920/2560 for the
desktop thresholds table.
- Before: 8 violations per drawer open on `/standalone/machines` and
`/docker/images`, 3 on Docker host and Proxmox guest drawers, 1 on the
HelpIcon popover, 5 on the typing dots, 1 on the threshold slider.
- After: 0 violations on all of those, plus the Proxmox node drawer,
storage pool detail and disk detail.
- Computed styles match the old ones: `overflow-anchor: none` on every
visible panel, dot delays of 0/0.12/0.24s and 0/0.15/0.3s,
`touch-action: none`, popover `min-width: 200px`.
- Rows opened below the fold (drawer 215 to 351px past it) did not move.
Other drift readings were identical in both builds.
- Vitest: 933 files pass across the affected directories. The only
failure is `agentInstallCommand.test.ts`, which fails on macOS
regardless of this change.
- A new MessageItem test fails on origin/main and passes here.
2026-10-07 10:42:21 +01:00
rcourtman 283ebaba6d Show a silent agent's retained disk usage as last known
Since #2634 a host agent past its reporting lease reads offline, and the
machine drawer marks its Thermals rows "(last known)". The Disks card in
the same Machine details kept presenting the agent's retained filesystem
usage as current: on a mock stack the silent host's root mount read 93%
in red with a red aggregate bar, beside Thermals reading "45°C (last
known)".

DisksCard now takes a lastKnownReason. With one, the total and each
mount keep their figures, because a disk that filled before the machine
went quiet is evidence, but read "Last known N%" in muted text titled
with the reason, without threshold colour or usage bars, as the guest
drawer's retained filesystem rows already do. The drawer derives one
agentReadingsLastKnownReason from agent.stale for Thermals and Disks
alike, so a Proxmox node that stays online with a silent linked agent
gets the same treatment.

Mount rows may now wrap. On a phone the row's label keeps its full
width, so a long mountpoint squeezed the value into a sliver and clipped
its byte figures; the label now takes its own line with the value below.
2026-10-07 10:21:25 +01:00
rcourtman ea1a07a721 Keep a source's own offline verdict through the stale pass
The registry's stale pass recomputed a resource's status from its source
sightings, and read a sighting that had delivered recently as "online".
A sighting only says the source delivered, not what it reported, so:

- A Proxmox node the cluster reports offline on a live poll came back
  online once its linked host agent went silent, which is what a
  powered-off machine's agent does. The Proxmox nodes table showed an
  amber dot beside its own "Offline" label while the drawer led with a
  critical "Node is offline" alert.
- A node the poller expired after failed polls read as a warning
  ("Degraded"), and the stopped guests on it counted as needing
  attention.

Each sighting now records the status its source reported, and the
aggregation reads that verdict. Current sources decide in the same
priority order the merge uses, so a live agent still keeps a node online
when the Proxmox API path to it fails. A source that went quiet drops
out while another is current. Once every source is quiet, an offline
verdict survives and anything else reads as warning, as before. This
generalises the lease marker that kept a silent agent offline, so a
stopped guest keeps its offline verdict too when its only source goes
quiet. A running guest whose only source goes quiet still reads as
warning.

Sightings still report delivery, so health keeps its telemetry_stale
reason. An in-memory copy keeps its verdicts; only a copy that lost
them all, a serialized one, takes its stored status as each source's
verdict.

Availability checks projected onto a target still count by delivery, as
before. Giving them a verdict needs each check's own freshness, so it is
left to a follow-up.
2026-10-07 10:17:49 +01:00
rcourtman 8c1c467190 Merge remote-tracking branch 'origin/main' into fix/drawer-inline-style-csp 2026-10-07 10:17:06 +01:00
rcourtman 579ff5ca0f Move static inline styles to classes the production CSP allows
The production server sends style-src 'self' 'nonce-...' with no
'unsafe-inline'. Solid compiles static style values (style="..." or a
literal entry of a style={{ ... }} object) into template HTML, so every
such style is a style attribute the browser reports as a style-src-attr
violation. Opening a row's inline detail on Machines or Docker Images
logged eight of these, and the HelpIcon popover, the Assistant typing
dots and the threshold slider logged their own. Chromium still applied
the copied styles, so the visible effect there was console noise;
whether a stricter browser applies them is not guaranteed. The Vite dev
server allows inline styles, so none of this showed in development.

The 24 drawer tab-panel overflow-anchor styles become the
[overflow-anchor:none] class, the typing-dot delays become arbitrary
animation-delay classes (animate-bounce already sets the 1s duration),
the slider uses touch-none, the HelpIcon popover keeps its dynamic
position in style and takes min-w-[200px], and the boot-failure
fallback is built with DOM nodes, which also stops the error text being
parsed as markup.

SharedPrimitives.guardrails.test.ts now compiles every runtime .tsx
that mentions style with the client-build Solid preset and fails on any
style attribute or <style> element in the resulting templates, nested
<template> content included. The regex scans first used to find these
missed a mixed style object in HelpIcon; the compiler-backed check did
not.

Contract-Neutral: Static inline styles become equivalent classes; only frontend-primitives gains a rule, recorded in its contract
2026-10-07 10:10:52 +01:00
rcourtman 79b6a0974e Name disk wearout as life remaining and log disk alerts once
The Proxmox disk wearout alert read "SSD has less than 10% life
remaining (7% wearout)". The number is percent life remaining, so
"7% wearout" reads as 7% worn and contradicts the first clause, and
the Alerts overview shows that text verbatim. It now reads "SSD life
remaining is 7%", the wording host-agent SMART wearout alerts already
use. The threshold stays in the alert's Threshold field, which the
card and notifications show beside the message; spelling it into the
sentence as "below 10%" would be false at exactly 10%, which alerts.

CheckDiskHealth also logged a failed disk at error and a worn disk at
warn on every evaluation, not when the alert opened. The physical
disk poll runs every 5 minutes by default, so one failed disk logged
an error 288 times a day for as long as it stayed failed, and mock
mode repeated it on every alert tick. Both now log at error or warn
only on the evaluation that opens the alert and at debug afterwards.

The new log test captures through a hook installed in TestMain:
assigning log.Logger inside a test would race with managers that
earlier tests leave running. The alerts contract also said wearout
recovery needs readings of at least 10%; a 10% reading still matches,
so recovery needs readings above it.
2026-10-07 10:10:46 +01:00
rcourtman a21e0ef8f8 Stop reports and resource history calling a moved alert recovered (#2607)
Follows #2591. A node alert that moves to its linked Pulse agent closes
with a `moved_to_agent` resolution (#2580). Two readers still presented
that close as a recovery.

**Performance reports.** The node, VM and container enrichers copied
recently resolved alerts into `reporting.AlertInfo` without the
resolution, so a report generated in the five-minute window after a
handover read HEALTHY, "All systems operating normally", counted the
alert as "triggered and resolved", and sent the AI narrator `resolved:
true`.

- Report rows now carry the alert's resource and resolution
(`reportActiveAlertInfo` / `reportResolvedAlertInfo`).
- `pkg/reporting` has one rule (`unresolvedAlerts`): an alert that
closed without recovering stays live unless the report also shows its
successor's reading for that metric, meaning an open alert or a recovery
since the handover. That way the condition is counted once, and fleets
match handovers across all their resources.
- The single-resource PDF card reads WARNING (or CRITICAL at the alert's
level) with "Alert moved to <agent> - check the agent for the current
reading". The active list names the agent, the alerts table shows
"Moved" with the engine's summary as a note, and recommendations say to
check the agent.
- Fleets count the alert as active and name the agent. Their all-clear
pattern and "No fleet-wide action required" no longer appear while any
alert is active.
- The narrator payload carries `resolved: false`, the summary and
`successor_alert_listed`, with prompt rules for both.

**Resource history.** `alert_resolved` changes were labelled by kind
alone ("Alert resolved", green) with that label prefixed to the
headline. `getResourceChangePresentation` now reads the
`alert_resolution` metadata: the change reads "Alert moved" in the
neutral blue of the history badge, and its summary is the whole headline
in the drawer, `ResourceChangeSummary` and Patrol's assessment handoff.

**Verification**
- A private mock stack running this branch, with a local-only overlay
that closes a real pve1 memory alert through `clearAlertWithResolution`.
Within the window, `/api/admin/reports/generate?resourceType=node`
rendered CRITICAL with the handover message and a "Moved" row, while the
ordinary CPU recovery stayed green.
- In the browser, a full drawer (Availability checks, Fleet view) showed
the producer-made rows as "Alert moved" next to "Alert resolved" at 1280
and 375. A Proxmox node's own drawer uses the `table-row` presentation,
which fetches no change history (see below).
- Tests in registered proof files: `pkg/reporting/alert_state_test.go`
(verdict, successor timing, fleet pool, PDF text and width),
`internal/api/contract_test.go`, `internal/ai/report_narrator_test.go`,
`resourceChangePresentation.test.ts`,
`ResourceDetailDrawer.history.test.tsx` and
`patrolInvestigationContextModel.test.ts`. Each failed with the fix
reverted.
- Second opinion: two read-only review rounds. The temporal match,
fleet-wide pool, narrator dedupe signal, fleet all-clear fallbacks and
contract wording came from them.

**Not fixed here (separate changes)**
- Reports requested as `agent` carry no alert rows at all. That is how
the Reports page addresses every Proxmox node in v6, so such a report
reads HEALTHY even with active critical alerts. This change makes the
shared rule correct for when those reports attach the machine's alerts.
- Node and guest alert lifecycle changes are recorded under legacy alert
resource IDs, which canonical history queries never match. Facet kind
counts still group moved closes under "Alert resolved".
2026-10-07 09:10:57 +01:00
rcourtman a570d9f335 Say the alert card's 16rem floor covers its icon column
The frontend-primitives note said the card's text column keeps a 16rem
floor, but the floor sits on the left column that holds the status icon
and the text, so the text itself gets about 230px before wrapping.
2026-10-07 08:48:47 +01:00
rcourtman 097858bc41 Merge remote-tracking branch 'origin/main' into fix/alert-card-live-reading 2026-10-07 08:45:23 +01:00
rcourtman bea0cec8b9 Keep PVE disk alert references out of resource history (#2627)
PVE disk health and wearout alerts reference the disk by instance, node
and device path (`<instance>:<node>:disk:<device key>`). Operator mutes
resolve that reference to the disk at the path (#2112), but resource
history leaves it unbound, so a physical disk's history (facets,
timeline and intelligence APIs, Assistant resource context) does not
show its own SMART and wearout alerts.

This records why that stays the decision, and pins it with a test.

- A device path is not hardware identity. A replacement disk in the same
slot, or a reboot that reorders devices, takes over the path, and disk
alerts are evaluated before their poll's disks reach the registry, so an
event can resolve against a generation that still places another disk
there.
- A history binding belongs to the reference string and moves to its
latest target, carrying every row journaled under the reference and
every read of it. Bound, the path would put a replaced disk's alerts
into its successor's history, and the Alerts history Resource action
(which reads incidents by the alert's own reference) would show the
successor's incidents for the predecessor's alert.
- A binding guarded by the serial each disk alert records was built and
measured on a mock estate (mock mode needed a build overlay to evaluate
PVE disk alerts at all). It bound correctly while disks kept their
paths, but review found rejected events still joining the currently
bound disk, a removed retained target accepting a successor's event, no
WWN in the alert metadata, a non-atomic check and write, and retry
expiry abandoning rows. That attempt is not in this PR.

Binding these alerts needs ownership per row (each event under the disk
its recorded hardware identity names, rows without usable identity left
under the reference, and a decided read for the Resource action), not a
per-reference binding. The contract paragraph in `unified-resources.md`
says so.

Test: `TestHistoryIdentityLeavesProxmoxDiskAlertReferencesUnbound` (live
event and backfilled legacy row stay under the reference and out of the
disk's history, while `ResolveReferenceID` still resolves the reference
for mutes). It fails against the reverted binding implementation.
2026-10-07 02:58:35 +01:00
rcourtman 458ee510c1 Replace stored alerts with each payload instead of merging (#2631)
The websocket store wrote each incoming active alert with
`setActiveAlerts(id, alert)`. Solid's path setter shallow-merges into
the existing object, so any field the server stops sending kept its old
value in the browser:

- After a backend restart, threshold alerts are restored without
`metricStatus` until their next evaluation (and indefinitely while the
reading is missing). An open tab kept the pre-restart live reading, so
the node drawer's "Needs attention" row and the node table temperature
tooltip went on saying "Temperature 78°C now" for a reading the server
no longer reports.
- An unacknowledge from another session left `ackTime`/`ackUser` behind,
as did a dropped `nodeDisplayName` or `metadata`.

Changes in `frontend-modern/src/stores/websocket.ts`:

- Active and recently resolved alerts are written with `reconcile`, so
omitted keys are removed on the snapshot, keyed-delta and REST recovery
commits alike. `reconcile` diffs nested values in place, so an unchanged
alert's live status no longer re-notifies keyed-store readers on every
frame (the shallow merge swapped in a fresh nested object each time).
- `updateAlert` stores a copy of each local update, because in-place
reconciliation would otherwise rewrite the record a snooze keeps for
rollback (its optimistic record shares nested objects with it).
- The pending acknowledgement hold is unchanged.

Tests (`websocket-unified.test.ts`): omitted `metricStatus`,
`nodeDisplayName` and `metadata` clear via snapshot, keyed delta (`null`
patch) and REST recovery; a server-side unacknowledge clears
`ackTime`/`ackUser`; local acknowledge/unacknowledge still wait for
confirmation; the rollback record stays intact; an unchanged alert's
nested reader is not re-notified; recently resolved alerts clear omitted
fields. The omitted-field, unacknowledge, reactivity and resolved tests
fail on the previous store. The rollback test fails if the `updateAlert`
copy is removed. The acknowledgement-hold test passes on both and guards
the existing behaviour.

Browser verification on a private mock stack at 1280×900 and 375×812,
with a local-only overlay that pins a standalone node's evaluated
temperature and lets mock mode restore alerts across restarts. Latch at
78°C (breach 95), restart the backend with the reading missing
(`/api/alerts/active` returns the alert without `metricStatus`), then
restore the reading:

- This change: the open tab's drawer and tooltip drop to "Node
temperature at 95.0°C" within seconds of reconnecting, then show "78°C
now" again on the next delta.
- Previous store, same run: the open tab kept "Temperature 78°C now" for
the full two-minute window.
- Snooze, resume and acknowledge on the Alerts card work with no page
errors. A remote acknowledge/unacknowledge round trip updates the open
tab within 2s.

Seen during verification and not changed here (it reproduces on main):
after acknowledging from the Alerts card, an unacknowledge from another
session does not bring the card back in that tab until a reload. The
cause is the acknowledgement override in `useAlertAcknowledgementState`.
It is tracked as a separate fix.
2026-10-07 02:54:37 +01:00
rcourtman f4ddeeb0e5 Give Docker containers and services without IDs their own alert refs (#2629)
Follow-up to #2620, which documented both conflations below as
residuals.

## Problem

Two Docker alert producers wrote names into identity slots:

- A container reported without an ID alerted under its Docker host's own
reference, `docker:<host>`. Its alerts showed on the host, joined the
host's resource history (which #2620 binds to the host), were never
cleared when the container left the report (cleanup matches
`docker:<host>/`), and Patrol registered `docker:<host>` on both the
host and the container, so the host's scope alias was ambiguous.
- A Swarm service without an ID put its normalized name in the
`/service/<ID>` slot, so a name equal to another service's Swarm ID
shared that service's alerts and history. #2620's resolver had to treat
every service in such a cluster as a conflict.

Pulse's own agent always sends Docker and Swarm IDs, so this needs a
malformed or third-party report. The registry still shows those
containers and services.

## Change

- `internal/alerts`: `DockerHostResourceID`,
`DockerContainerResourceID(host, id, name)` and
`DockerServiceResourceID` are the only builders. An ID-less container
alerts under `docker:<host>/name:<name>` and an ID-less service under
`docker:<host>/service/name:<normalized name>`. Docker and Swarm IDs
never contain a colon, so neither can equal a host or ID reference. One
with neither an ID nor a name raises no alert.
- The container and service policy switches classify by the first path
segment after the host, so a container name containing `/service/` stays
a container.
- Report cleanup, host removal and the host's offline alert also clear a
pre-upgrade container alert left on exactly `docker:<host>` (recorded
host must match, container ID empty). A pending image update recorded
there keeps its age when the first report still shows it pending.
- Canonical state IDs were split at the first `::`, but colliding Docker
hosts get IDs like `<base>::<suffix>`. Every container alert of such a
host was rewritten to `docker:<base>`, the other host's reference: it
joined that host's history and missed its own host's cleanup.
`splitCanonicalStateID` now splits before the alert's recorded spec ID,
else after its resource ID, so alerts saved the old way also recover on
restore.
- Patrol registers the same builders' output as scope aliases, with the
container's name.
- History: a service slot containing `:` is a name reference (never
binds, never retried), and the cluster-wide conflict for ID-less
services is gone. A real service ID reference now binds even when its
cluster also has an ID-less service.
- Contract notes in alerts, ai-runtime, monitoring and
unified-resources.

Remaining legacy boundary (in the unified-resources contract): rows
journaled before this change keep their old reference. An ID-less
container's old rows stay in its host's history, and an ID-less
service's old rows bind only to a service whose ID equals its normalized
name.

## Proof

- `TestDockerAlertsWithoutIDsUseTheirOwnReferences` (real
`CheckDockerHost`: fired and active references, removal cleanup,
pre-upgrade leftover on report and offline, config-save
container/service switches).
- `TestDockerHostIDWithSeparatorKeepsItsAlertReferences`,
`TestDockerAlertsRestoredWithCutShortReferencesRecover` (writes
`active-alerts.json`, restores through `LoadActiveAlerts`) and
`TestDockerContainerWithoutIDKeepsPendingUpdateAgeAcrossUpgrade`.
- `TestOwnerAlertTimelinesUseCanonicalHistoryIdentity`: real producers
into the SQLite history. The ID-less container and the service named
like `web`'s Swarm ID stay out of the host's and `web`'s histories.
- `TestResolvePatrolScopeResolvesDockerAlertResourceIDs`: the host's
alias stays unambiguous, and the ID-less container's name reference
resolves to the container in the unified read state.
-
`TestHistoryIdentityMonitorAdapterResolvesDockerHostAndServiceReferences`:
the ID reference binds before and after the service leaves inventory,
and name references never bind or retry.
- Mutation check: putting back each old behavior fails at least one of
these tests. Covered: host fallback, unmarked service name, no skip for
unidentifiable containers or services, each legacy clause and its host
check, the update-age carry-over, first-`::` splitting with and without
the spec rule, substring classification, the Patrol alias without the
name, and the resolver accepting a `name:` slot.
2026-10-07 02:52:45 +01:00
pulse-triage[bot] 44569ddbf0 Merge branch 'main' into fix/active-alert-store-replace 2026-10-07 02:16:55 +01:00
pulse-triage[bot] 809629954d Merge branch 'main' into fix/docker-alert-unique-refs 2026-10-07 02:16:52 +01:00
pulse-triage[bot] d4ab4a2324 Merge branch 'main' into fix/pve-disk-alert-history 2026-10-07 02:16:49 +01:00
pulse-triage[bot] c48289160d Merge branch 'main' into fix/workload-hover-hint-reflow 2026-10-07 02:16:43 +01:00
rcourtman e3e743b076 List disk heat beside TrueNAS alert text
The TrueNAS storage table's Health reasons put native TrueNAS alert text
first and used Pulse's derived summaries only when TrueNAS raised no
alert. Disk heat landed in that fallback, so a hot disk with any
incident, even an informational one, showed only the incident's text
under Attention, and nothing said the disk was hot.

Heat is Pulse's own judgement against the alert disk temperature
thresholds, not a restatement of TrueNAS state, so it is now always
listed after any native alert text.
2026-10-07 01:20:02 +01:00
rcourtman 396bb5d517 Keep a disk heat finding open without a current reading
Patrol verifies a disk-high finding as recovered once the disk's
temperature cools to its alert clear value. A disk in standby, or one
whose host agent stopped reporting, has no current reading, and a
missing reading counted as cooled. A finding raised for a SATA disk at
56C would close as soon as the disk spun down, while its temperature
alert stayed open.

When a disk has no current reading and its last known reading is still
at or above the clear value, verification now reports the recovery as
unknown instead of proven. A disk last seen cool still recovers.
2026-10-07 01:19:36 +01:00
rcourtman 35bf55af23 Colour report disk temperatures by the alert thresholds
The PDF performance report's disk table coloured a temperature amber
from 50C and red from 60C for every disk type, the last fixed disk heat
line after disk heat moved to the alert disk temperature policy. An NVMe
at 63C, well under its 70C alert trigger, printed red. A user's raised
trigger changed nothing.

Each reported disk now carries the tenant's alert thresholds for its
type (the clear value and the trigger, from
alerts.Manager.DiskTemperatureThreshold). The PDF colours the reading
amber from the clear value and red from the trigger, as the Physical
Disks Temp column does, and leaves it plain when disk temperature
alerting is off.
2026-10-07 01:19:36 +01:00
rcourtman 3fd5411ecc Judge disk heat by the alert disk temperature thresholds
Pulse decided whether a physical disk was too hot in three places with
three thresholds. Disk risk added temperature_high at a flat 60/70C for
every disk type and drove the disk status and the Physical Disks Health
verdict. Patrol flagged anything over 55C and the AI chat context listed
disks from 50C. Alerts and the Temp column used the per-type thresholds
users tune in Alerts (NVMe 70, SAS 65, SATA 55 by default). An NVMe at
63C read Running Hot beside a green Temp cell. A SATA disk at 56C read
Healthy beside a red one. A user who raised the NVMe trigger to 75 still
got a hot verdict at 70.

The alert disk temperature policy now decides disk heat.
alerts.Manager.DiskTemperatureThreshold exposes the per-type resolution
CheckHost already applies.

- Disk risk no longer judges temperature. Every registry that rebuilds
  risk lacks the alert configuration, so no rule there could honour it.
  A hot disk keeps an online status.
- The Health verdict reads Running Hot when a current reading reaches
  the disk type's trigger. It uses the thresholds object that colours
  the Temp cell, so the two cannot disagree. The amber band below the
  trigger stays a cell colour, not a verdict.
- The TrueNAS storage table lost the heat it used to get from risk. It
  now judges heat the same way: an Attention row with the reason, and a
  warning dot for phones.
- Patrol triage, its disk issue filter, finding recovery and the chat
  context's attention list judge the collected reading against the same
  trigger, including scoped runs and a provider set before Patrol
  starts.
- A switched-off agent Disk Temp default resolves to no thresholds,
  which the displays that read them now treat as off.

Per-host Disk Temp overrides do not reach these surfaces yet. TrueNAS
disk alerts, the TrueNAS disk drawer and PDF reports keep their own
fixed temperatures for now.
2026-10-07 01:19:31 +01:00
rcourtman 1b6a9b45d7 Keep retained disk temperatures out of AI, Patrol and reports (#2625)
Normalization keeps a disk's last-known temperature when the current
observation did not collect one (a disk in standby, a host agent past
its reporting lease) and marks it non-available. These readers still
took `PhysicalDisk.Temperature` at face value; each now goes through
`diskinventory.TemperatureCollected`:

- **AI chat context**: "Physical Disks Needing Attention" no longer
flags a disk for heat from a retained reading. A disk listed for another
reason shows it as `last known 52C (host agent stopped reporting)`.
- **Patrol**: the disk health-issue gate, triage flags and `disk-high`
finding verification judge only a collected reading. Both seed tables
label a retained one as last known.
- **AI tools**: `pulse_query` / `pulse_metrics` physical disks and
`pulse_storage disk_health` put a retained value in
`last_known_temperature` + `last_known_temperature_reason` instead of
`temperature`.
- **Reports**: the performance report's disk table and the reporting
runtime snapshot leave a retained value out (no reading).
- **Disk temperature charts**: a short series is padded out to now only
with a collected reading. Otherwise stored samples are returned as they
are.
- `monitor.go`'s private copy of the predicate is replaced by the shared
helper.

Each new test reproduces against the pre-change code: retained
`unavailable` temperatures (standby / silent agent) beside a collected
one.
2026-10-07 01:18:08 +01:00
rcourtman 684c0dc598 Show a host whose agent stopped reporting as offline (#2634)
A host agent that stops reporting is never shown as offline in the
Machines table. Its row renders its last report as if it were current.

## Why

The monitor marks a host agent offline once its reporting lease expires,
and raises "Host is offline". The registry's stale pass marks the agent
sighting stale after 60 seconds, and `aggregateStatus` ranked stale
above offline. So the machine reached every consumer as `warning`.

On the Machines table that meant an amber dot. CPU, memory, disk,
network, disk I/O, uptime and temperature all rendered as live, with
threshold colours. Meanwhile the row's own drawer led with the critical
offline alert and read "Observed state: Degraded".

Docker hosts and Kubernetes clusters hold the same kind of lease, and
theirs is shorter than the 120-second stale threshold. A silent Docker
host therefore read offline at first, then flipped to warning once its
sighting went stale.

## What changes

- **Backend.** Each sighting records when a push reporter delivered its
machine, Docker host or cluster as offline. The reporters are the host
agent, Docker and Kubernetes. Status aggregation counts that sighting as
offline whatever its age.
- The sighting itself still reads `stale`, so health keeps its
`telemetry_stale` reason and monitored-system reasons are unchanged.
- A live source still carries a merged row. A Proxmox node whose agent
died stays online through the PVE poll.
- Pull sources, and the guests, containers, pods and disks that push
collectors report about, keep the stale-to-warning rule.
  - A JSON copy re-derives the marker from its stored status.
- A manual link joining two resources from one source keeps the fresher
sighting.
- **Frontend.** Offline rows already blank CPU, memory, disk, network
and disk I/O through the danger gate.
- The Machines Uptime and Temp cells and the Docker hosts Temp cell had
no gate, and now use it.
- The drawer drops an offline resource's uptime and a silent agent's
uptime.
- The drawer marks a silent agent's non-disk Thermals rows "(last
known)" with the reason as title, as retained disk rows already are.

## Verification

- Go tests cover the agent, Docker, Kubernetes, mixed-source,
Proxmox-node, JSON round-trip and manual-link cases, plus the unchanged
late-agent and stale-PVE boundaries. Each fix's test fails without it.
The `unifiedresources` package passes.
- The `api`, `monitoring`, `ai`, `alerts` and `agentcontext` suites ran
on pulse-dev. One `internal/api` test failed there,
`TestServerInfoEndpointReportsDevelopment`, and it fails identically on
the pre-fix build in that scratch tree.
- Frontend tests cover the Machines offline row, the Docker offline Temp
cell, the drawer thermals marking and the drawer uptime rows. The
adjacent suites pass.
- Live check on a private mock stack, holding one standalone Linux agent
and one Docker host past their leases, at 1440 and 390 wide. Before:
amber row with live-looking readings. After: red row with every reading
blanked, the drawer reading "Offline", and thermals marked last known.
- The browser receipt is included. Bundle sizes are within budget:
WorkloadsSurface is unchanged, Standalone +0.04 kB, Docker +0.01 kB.

Builds on rcourtman/Pulse#2630 and updates its contract sentence, which
described the lapsed agent as reaching the table as `warning`.

Known follow-up: the drawer's Disks card still threshold-colours a
silent agent's retained filesystem usage.
2026-10-07 01:03:44 +01:00
rcourtman fb55d47517 Raise disk health alerts for mock physical disks (#2633)
`checkMockAlerts` evaluated guests, agents, nodes, storage, PBS, PMG and
Docker against the mock estate but never physical disks. The mock
generator deliberately keeps a FAILED health cohort and worn SSDs stable
across restarts, so mock and demo stacks showed failing and "Replace
Now" disks with no `disk-health` or `disk-wearout` alert, and the disk
alert lifecycle could not be exercised without real hardware.

## Change

- `checkMockAlerts` now evaluates fixture disks through
`checkMockPhysicalDiskAlerts`, applying the physical disk poller's
boundary: only disks on online nodes (matched by instance and node
name), with the linked agent's `--disk-exclude` patterns forcing a
matched device healthy.
- The poller's per-disk rule (exclusion forces healthy, else
`CheckDiskHealth`) moves into `checkPhysicalDiskAlerts`, shared by
`maybePollPhysicalDisksAsync` and the mock pass. The poller's behaviour
is unchanged; the source guardrail
`TestProxmoxDiskAlertsRunOnMergedDiskState` now pins the helper call.
- The poller's host-agent link settle deferral (#1674) is not applied in
mock mode: fixture links and exclusions are complete from the first
pass.

## Demo-facing effect

Measured on private stacks with the public demo's exact estate
(`/etc/pulse/.env`: 8 nodes, `DEMO_MODE`), baseline vs patched: the demo
estate has 12 Proxmox disks and none FAILED. The only new disk alert is
one warning-level `disk-wearout` ("SSD has less than 10% life remaining
(7% wearout)" on West Production E), whose resource ID is one of the
disk resource's canonical aliases. No `disk-health` alert opens on that
estate. The default test estate (50 nodes) raises five `disk-health` and
one `disk-wearout` alert.

## Known gaps, left out of scope

- Like the other loops in `checkMockAlerts`, the disk loop relies on the
pass's single up-front mode check, so a pass in flight when mock mode is
switched off can reopen alerts after `ClearActiveAlerts`. That race
predates this change and also involves the frontend refresh and backup
rollup paths, so it needs a monitor-wide fix rather than a lock around
this pass alone.
- `CheckDiskHealth` logs at error/warn on every evaluation of a FAILED
or worn disk, so in mock mode such a disk logs once per alert tick.
- The wearout message calls 7% life remaining "7% wearout".

## Verification

- `TestCheckMockAlertsEvaluatesPhysicalDisks` (pinned default estate)
and `TestCheckMockPhysicalDiskAlertsFollowsPollerBoundary` (offline
node, same node name on another instance, exclusion resolving existing
alerts, node departure cleanup). Mutations that drop the pass, ignore
offline nodes, ignore exclusions, or key by node name only each fail a
test.
- Full `go test ./internal/monitoring/` and the mock tests under `-race`
pass.
2026-10-07 01:00:56 +01:00
rcourtman efd3b29ac8 Poll only the history each metric table renders (#2632)
`useWorkloadTableMetricHistory` ran two polled estate-wide reads per
instance in Trends mode: the workloads history (`/api/charts/workloads`)
and the infrastructure summary (`/api/charts/infrastructure`). Since
#2622 removed the host metric cells from grouped workload rows, the
Workloads surface reads only guest series, and the Proxmox nodes table
never read anything but node series. Two instances never shared reads
either: each forwards its own abort signal, which skips the summary
caches' in-flight dedupe, and retained query values only seed matching
mounts and source changes without sharing requests. So every reader paid
for both routes every 30 seconds.

The hook now takes a required `series: 'guests' | 'nodes'` option. The
Workloads state passes `'guests'` and the Proxmox nodes table passes
`'nodes'` (and drops the `selectedNode` it never needed), so each reader
polls one route.

### Measured
Private demo-mode mock stack, Trends on, 95 s per page (first read +
three 30 s polls), reads per poll tick:

| Page | Viewport | Before (infra + workloads) | After (infra +
workloads) |
|---|---|---|---|
| /vmware | desktop 1440x900 | 1 + 1 | 0 + 1 |
| /vmware | phone 390x844 | 1 + 1 | 0 + 1 |
| /proxmox/overview | desktop 1440x900 | 2 + 2 | 1 + 1 |
| /proxmox/overview | phone 390x844 | 2 + 2 | 1 + 1 |

Node table sparklines on /proxmox/overview still draw for every row (36
on desktop, 18 on phone), and the guest table sparklines are unchanged.

### Tests
- `useWorkloadTableMetricHistory.test.tsx`: a `'guests'` reader polls
only the workloads route and a `'nodes'` reader only the infrastructure
route, across two polls. Both cases fail with the gating reverted.
- `ProxmoxNodesTable.test.tsx`: the table asks for node history only,
with no node scope, and enables it only in Trends.
- `WorkloadsSurface.performance.contract.test.tsx` pins both call sites
and the gating.

The performance contract claimed the two readers deduped their fetches;
it now says they do not and that each reader polls only what it renders.
The adjacent Bars-mode prose (performance and frontend-primitives
contracts) described one active-row request that leaving the row
cancels; it now describes the bounded warming window the hook has
actually run since the history lens shipped.

Stacked on #2622, whose auto-merge is already on: this branch contains
its commits and merges cleanly with main either way round.
2026-10-07 00:53:10 +01:00
rcourtman 4c4e6e7c57 Call a disk that is only hot Running Hot, not Replace Now (#2623)
On Physical Disks, a SMART-passed disk with good life running at 72°C
read **Replace Now** ("Disk temperature is 72C"), and **Replace** on
phones. Disk risk grades temperature (`temperature_high`, ≥70°C
critical, ≥60°C warning), and the Health verdict mapped any critical
risk to Replace Now. That tells a homelab user to buy a disk when the
fix is airflow. Alerts already keep heat separate: the SMART health and
wearout alerts skip `temperature_high`, and disk heat is raised as a
Temperature metric.

## Change

- `diskPresentation.ts` keeps each risk reason's code and severity, and
splits the reasons into heat and everything else.
- FAILED health, any other critical reason, or a critical level that no
reason explains → **Replace Now** (unchanged).
- Heat alone → **Running Hot** (phone **Hot**). Red at the critical
tier, amber at warning. The temperature reason is still the cell title
and the drawer summary.
- Other warning evidence, SMART counters or low life → **Needs
Attention** (unchanged).
- The more severe class names the verdict, and failure evidence wins a
tie. Merged risks that list heat first (`mergePhysicalDiskRisk` does not
re-sort) no longer hide pending sectors behind a temperature summary.
- Sort priority and the health filter derive from the same verdict.
Order is Replace Now, then red Running Hot, then Needs Attention, then
amber Running Hot. Hot disks stay in **Needs attention** and land in
Critical or Warning by their tier.
- Data with no reason codes keeps the old behaviour.

## Not changed

Thresholds. The tier still comes from the server's flat 60/70°C rule,
not the per-type alert thresholds that colour the Temp cell. An NVMe at
60–64°C therefore reads amber Running Hot next to a green Temp cell.
That disk read Needs Attention before this change, so the judgment is
the same and only the word is clearer. Reconciling the thresholds is
being done separately.

## Verification

- New tests in `diskPresentation.test.ts` cover heat at both tiers,
failure evidence listed after heat, heat plus wear, an unexplained
critical level, sort order and the health filter. Five of them fail on
the old code, including the sort test, whose device names make name
order alone disagree. `DiskList.test.tsx` checks the full and compact
words, tone, title and row order.
- Targeted suites (storageBackups, Storage, GuestPhysicalDisks,
frontendResourceTypeBoundaries): 89 files, 1513 tests pass. tsc and
eslint are clean, and the canonical platform audit is clean.
- Browser check on a private mock stack (local, uncommitted generator
overlay pinning pve3 disks at 72°C, 63°C, and 72°C with 2 pending
sectors), at 1440×900 and 390×844. The rows read Replace Now / Running
Hot / Needs Attention / Running Hot (phone: Replace / Hot / Attention /
Hot) with the expected tones and titles. The drawer reads "Running Hot ·
Disk temperature is 72C". The `status=attention|critical|warning`
filters list the expected rows. A same-session control with the old file
showed the 72°C disk as Replace Now. The receipt is
`frontend-modern/browser-verification/259ae9ced29e073f.json`.
2026-10-07 00:47:36 +01:00
rcourtman c06af69fcb Show a host whose agent stopped reporting as offline
A host agent past its reporting lease is offline: the monitor marks it
so and raises "Host is offline". The registry's stale pass ranked the
agent's stale sighting above offline, though, so the machine reached
every consumer as a warning. The Machines table showed it amber and
rendered its last CPU, memory, disk and temperature as current readings,
while its own drawer led with the critical offline alert. A Docker host
was offline only until its sighting crossed the 120-second stale
threshold, then flipped to warning, and Kubernetes clusters likewise.

Each sighting now records when a push reporter (host agent, Docker,
Kubernetes) delivered its machine, Docker host or cluster as offline,
and status aggregation counts that as offline whatever the sighting's
age. The sighting itself still reads stale, and a live source still
carries a merged row, so a Proxmox node with a dead agent stays online.

A copy that went through JSON re-derives the marker from its stored
status, and a manual link joining two resources from one source keeps
the fresher sighting, so a retired agent cannot pull a live one down.

Offline rows then take the existing danger gate. The Machines Uptime
and Temp cells and the Docker hosts Temp cell had no gate and now
follow it. The drawer drops a silent agent's uptime and marks its
non-disk Thermals rows "(last known)", as retained disk rows already
are.
2026-10-07 00:14:49 +01:00
rcourtman 3c1464e42f Merge remote-tracking branch 'origin/main' into fix/disk-temp-consumers 2026-10-07 00:12:17 +01:00
rcourtman 344f1f8531 Raise disk health alerts for mock physical disks
checkMockAlerts evaluated guests, agents, nodes, storage, PBS, PMG and
Docker against the mock estate but never physical disks. The fixture
keeps a FAILED cohort and worn SSDs stable on purpose, so mock and demo
stacks showed failing and "Replace Now" disks with no disk-health or
disk-wearout alert, and the disk alert lifecycle could not be exercised
without real hardware.

The pass applies the physical disk poller's boundary: only disks on
online nodes, matched by instance and node name, with the linked
agent's --disk-exclude patterns forcing a matched device healthy. The
per-disk evaluation moves into checkPhysicalDiskAlerts so the poller
and the mock pass share one rule. The poller's agent-link settle
deferral is not applied, because fixture links and exclusions are
complete from the first pass.

Like the other loops in checkMockAlerts, the disk loop runs after the
pass's single mode check, so the existing race with leaving mock mode
now covers disk alerts too. That race also involves other fixture
evaluation paths and is left for a monitor-wide fix.

On the public demo's estate (8 nodes) this adds one warning-level
disk-wearout alert and no disk-health alert: that estate has no FAILED
Proxmox disk.
2026-10-06 23:43:53 +01:00
rcourtman 8732bc1923 Remove the unreachable host metric cells from grouped workload rows (#2622)
Both production mounts of the Workloads surface, the Proxmox and vSphere
overviews, set `compactGroupHeaders` unconditionally. The non-compact
branch in `WorkloadPanel` (per-column node CPU, memory, disk and I/O
bars or sparklines, uptime, version, temperature) and the
`NodeGroupHeader` column mode and inline host facts that only it reached
never rendered. They were left over from the retired standalone
Workloads page.

This removes the option, the dead renderers and the `NodeGroupHeader`
props that only served them. Grouped host rows keep the shape both pages
already render: one spanning cell with status, linked name, cluster and
agent badges. Host stats stay in each page's own hosts table.

Verification
- No other production consumer: `WorkloadsSurface` → `WorkloadsTable` →
`WorkloadPanel` is entered only from `ProxmoxPageSurface` and
`VmwarePageSurface`; no references in pulse-pro, pulse-enterprise or
pulse-mobile.
- Browser pass on a production build embedded in a private mock backend,
`/proxmox/overview` and `/vmware` at 1440x900 and 390x844, Bars and
Trends modes: every grouped host row is one cell spanning the guest
table's visible columns, with no metric cells or host facts; group-row
clicks open nothing; guest drawers still open; no page errors.
- The rewritten `NodeGroupHeader` test fails against the old component
(its default facts render `PVE 9.1.9`).
- `tsc`, full `npm run lint`, vitest over Workloads, shared, features,
hooks, utils, Storage and app tests (only the Mac-only
`agentInstallCommand` failures, unrelated), bundle budgets, and all
release-control guards pass.
2026-10-06 23:42:36 +01:00
rcourtman 8e93360d10 Poll only the history each metric table renders
useWorkloadTableMetricHistory ran two polled estate-wide reads per
instance in Trends mode: the workloads history and the infrastructure
summary. Since grouped workload rows lost their host metric cells, the
Workloads surface reads only guest series, and the Proxmox nodes table
never read anything but node series. Each reader passes its own abort
signal, which skips the summary caches' in-flight dedupe, so every
instance paid for both routes every 30 seconds.

A required series option now names the rows a caller draws. The
Workloads state passes 'guests' and the Proxmox nodes table passes
'nodes', so each polls one route. On the demo estate in Trends this
takes the vSphere overview from one infrastructure and one workloads
read per poll to one workloads read, and the Proxmox overview from two
of each to one of each. Node table sparklines are unchanged.

The performance contract claimed the two readers deduped their reads;
it now states that they do not and that each reader polls only what it
renders. The adjacent Bars-mode prose in the performance and
frontend-primitives contracts described one active-row request that
leaving the row cancels; it now describes the bounded warming window
the hook has run since the history lens shipped.
2026-10-06 23:40:52 +01:00
rcourtman 1c101b024d Replace stored alerts with each payload instead of merging
The websocket store wrote every incoming active alert with
setActiveAlerts(id, alert). Solid's path setter shallow-merges into an
existing object, so a field the server stops sending kept its old value
in the browser. After a backend restart, threshold alerts are restored
without metricStatus until their next evaluation, but an open tab kept
the pre-restart live reading and the node drawer and node table went on
saying "78°C now" for a reading the server no longer reports. An
unacknowledge from another session left ackTime and ackUser behind the
same way, as did a dropped nodeDisplayName or metadata.

Active and recently resolved alerts are now written with reconcile, so
omitted keys are removed on the snapshot, keyed-delta and REST recovery
commits alike. reconcile diffs nested values in place, so an unchanged
alert's live status no longer re-notifies its readers on every frame,
which the shallow merge did by swapping in a fresh object each time.
The pending acknowledgement hold is unchanged: it still skips payloads
that contradict an unconfirmed local acknowledge or unacknowledge.

updateAlert now stores a copy of each local update. Because payloads
reconcile the stored alert in place, a snooze's optimistic record, which
shares nested objects with the record the caller keeps for rollback,
would otherwise have that rollback record rewritten by the next payload.
2026-10-06 23:30:01 +01:00
rcourtman 38954b0b71 Give Docker containers and services without IDs their own alert refs
A container reported without an ID alerted under its Docker host's own
reference (docker:<host>). Its alerts showed as host alerts, joined the
host's resource history, were never cleared when the container left the
report, and made the host's Patrol scope alias ambiguous. A Swarm service
without an ID put its normalized name in the service-ID slot, so a name
equal to another service's ID shared that service's alerts and history,
and the history resolver had to treat the whole cluster as a conflict.

Both now alert under their name, marked in the ID position
(docker:<host>/name:<name>, docker:<host>/service/name:<name>). Docker
and Swarm IDs never contain a colon, so these cannot equal a host or ID
reference, and one with neither an ID nor a name raises no alert. The
policy switches, cleanup, Patrol aliases and the history resolver use the
same builders and parse the same shapes, so the resolver no longer needs
the cluster-wide conflict. A pre-upgrade container alert left on the
host's reference is cleared on the host's next report, and a pending
image update recorded there keeps its age.

Canonical state IDs were split at the first "::", but Docker hosts that
collide are disambiguated as "<base>::<suffix>". Every container alert
of such a host was rewritten to the reference of the host named <base>,
so it joined that host's history and skipped its own host's cleanup.
State IDs now split before the alert's recorded spec ID, or after its
own resource ID, so alerts saved that way also recover on restore.
2026-10-06 23:27:50 +01:00
rcourtman 723f3165a1 Show a silent agent's retained disk temperatures as last known
When a host agent passes its reporting lease, its collected SMART
temperatures keep their values with collection.temperature unavailable
(#2593), and #2603 taught the Storage disk surfaces to show such a value as
last known. The frontend HostDiskSMART type never carried `collection`, so
the agent's own surfaces kept presenting the retained readings as current:

- The Machines temperature cell fell back to the hottest SMART reading and
  coloured it against the disk thresholds. On a live mock stack a silent,
  SMART-only agent read 52°C in threshold green.
- Its tooltip, the machine drawer's Thermals rows and the guest drawer's
  Physical Disks card listed the retained disks as plain readings.

The row does not make this obvious. By the time the lease expires the
registry has marked a standalone agent's source stale, which outranks
offline, so a silent agent reaches the table as `warning` with
`agent.stale`, not `offline`. The offline metric fallback never blanks its
cells, and the temperature cell has no such gate.

Now `HostDiskSMART.collection` is typed and every reader decides through
`isPhysicalDiskTemperatureCurrent`:

- A retained disk reading never stands for the machine while another disk is
  current. When no non-standby disk is current and the machine has no
  positive direct or temperatureCelsius reading, the cell shows the hottest
  retained non-standby value muted with a dotted underline and screen-reader
  "last known", without threshold colour.
- Tooltip and Thermals rows read "(last known)", the Thermals row with the
  collection reason as its title. A disk with a zero or negative reading no
  longer shows a temperature row there.
- The guest Physical Disks card uses the Storage table's last-known
  treatment.

The temperature helpers move to diskTemperaturePresentation.ts, which
diskPresentation.ts re-exports. On the earlier base, importing
diskPresentation from the Machines page and the drawer mapper folded the
whole Storage presenter into the shared chunk (+4.9 kB gzip) and renamed its
tracked budget entry, failing the bundle check. With the small module,
Standalone grows 0.22 kB, the shared chunk 0.20 kB, and Workloads is flat
against a build of main. The guest card still loads diskPresentation for
extraction and health.

Direct and temperatureCelsius readings carry no collection state and still
lead the cell. A stale agent's CPU, memory, disk and sensor values still
render as current. That is a row-wide decision, left for a separate change.
2026-10-06 23:23:18 +01:00
rcourtman 8424fd6505 Show real CPU low and record in the Proxmox node drawer (#2594)
The Proxmox node drawer's **CPU low** and **CPU record** rows repeated
the current temperature for every node without a host agent.

`nodeFromResource` never read `proxmox.temperatureDetails`, which is
where the resource API publishes the poller's full reading, including
its low and record values. For those nodes it fell back to the scalar
`proxmox.temperature` and copied that one value into both fields. On the
dev stack, `delly` and `minipc` showed CPU low = CPU record = current.

- The adapter now takes the first source with a usable reading.
`temperatureDetails` comes after the platform record and the agent
facet, so agent-linked nodes render exactly as before.
- The bare-reading fallback leaves low and record unset, so the drawer
omits those rows rather than inventing them.

The poller also reset those values to the current reading every poll.
That is a separate backend fix to the previous-node projection, landing
in its own PR. Until it lands, PVE-only nodes will still show low =
record = current, now because that is what the backend reports.

### Verification
- Unit tests pin the API's real payload shape (scalar
`proxmox.temperature` plus `proxmox.temperatureDetails`), the no-record
fallback, and agent-facet precedence. The new test fails against main's
adapter, which returns `cpuMin` 58 = current.
- Browser pass on a private mock stack at `/proxmox`, 1440x900 and
390x844, with REST and websocket payloads rewritten to make one node
PVE-only:
  - main's adapter showed CPU low 82°C / CPU record 82°C;
- this change shows no rows without a record, and 39°C / 91°C with one;
  - agent-linked nodes and the table TEMP column are unchanged.
- `vitest` over `src/utils`, `src/components/Workloads`,
`src/features/proxmox` and `src/features/platformPage` passes, apart
from `agentInstallCommand.test.ts`, which fails on macOS regardless of
this change.
2026-10-06 23:21:38 +01:00
rcourtman 18f0fbd826 Keep PVE disk alert references out of resource history
PVE disk health and wearout alerts reference the disk by instance, node
and device path. Operator mutes resolve that reference to the disk at
the path (#2112), but resource history must not bind it: a replacement
disk in the same slot, or a reboot that reorders devices, takes over the
path, and disk alerts are evaluated before their poll's disks reach the
registry. A history binding belongs to the reference string, so it would
carry a replaced disk's journaled alerts into its successor's history
and make the Alerts history Resource action show the successor's
incidents for its predecessor's alert.

A binding guarded by each alert's recorded disk serial was built and
measured on a mock estate. It bound correctly while the disk stayed at
its path, but rejected events still joined whichever disk the reference
was bound to, and the serial check cannot be atomic with journal writes.
Record the decision in the unified-resources contract and pin it with a
test, so binding these alerts later goes through ownership per row
rather than a per-reference binding.
2026-10-06 23:20:20 +01:00
rcourtman 7b362b3532 Load guest physical disks with the drawer to keep Workloads in budget
GuestDrawerOverview statically imported GuestPhysicalDisks, which pulled
all of diskPresentation.ts into the WorkloadsSurface table chunk. With the
Running Hot verdict that chunk measured 70.64 kB gzip against its
70.23 kB cap, so the Frontend bundle check would fail.

The card renders only inside an open drawer for a guest with a Pulse
agent, so the overview now lazy-loads it under a local Suspense, the same
pattern as the bulk web-link editor. WorkloadsSurface drops to 64.31 kB
and the disk presentation builds as its own chunk. The contract test pins
the dynamic import.
2026-10-06 23:16:04 +01:00
rcourtman fb7dd955ed Keep retained disk temperatures out of AI, Patrol and reports
Normalization keeps a disk's last-known temperature when the current
observation did not collect one (a disk in standby, a host agent past
its reporting lease) and marks it non-available. Several readers still
took PhysicalDisk.Temperature at face value. The AI chat context listed
a standby disk as needing attention for heat. Patrol raised health
issues and triage flags from the retained value. The AI query and
disk-health tools handed it to the model as the disk's temperature.
Performance reports and the reporting runtime snapshot tabulated it as
measured, and the disk temperature charts padded a series out to now
with it.

Each of them now goes through diskinventory.TemperatureCollected. AI
surfaces keep a retained value as context, labelled last known with
its reason. Reports and the charts leave it out. monitor.go's private
copy of the predicate is replaced by the shared helper.
2026-10-06 23:09:52 +01:00
rcourtman d9ffb39ce8 Keep a merged disk's temperature under its own row's state (#2618)
Stacked on #2609; until it merges this branch also carries its commit,
and the change here is the last commit.

## Why

The registry merges a physical disk's rows (Proxmox, host agent SMART,
Unraid inventory), choosing the temperature by source preference and
merging collection states separately. When two rows carry different
temperatures, the shown value could sit under the other row's
`available` state. A real case: a node whose disk temperature Proxmox
collects now (`proxmox_node_smart`, 40) and whose linked host agent has
gone silent (retained 72, `unavailable`). In either ingest order the
merged disk showed **72 labelled available**, so risk, history and the
UI treated the frozen reading as current.

## What

- `pairPhysicalDiskTemperatureState` runs at the end of each merge step.
When the merged state says collected but the row the shown value came
from says it was not, the merged state becomes that row's own.
- It never changes a value and never grants availability, so it cannot
revive a withdrawn reading or override a resumed agent's fresh one.
- Rows with the same value keep the merged state, so the agent's own
withdrawal still supersedes the Proxmox copy (#2593).
- With that guarantee the Unraid-native row no longer needs to withhold
`available` (#2609 restricted it), so it carries the same derived state
as a SMART row borrowing its reading.

## Known limit

This is exact for the two rows of one merge step. The merged disk keeps
one state per field, so with three or more rows for one disk, a
withdrawal that an earlier step replaced is invisible to a later step.
Sol found two such counterexamples, both needing three rows (one also
needs coincidentally equal values, the other a legacy agent plus two
Proxmox rows). Closing that needs each reading's own state tracked
across merges, and that redesign is filed separately. Preferring another
source's current value instead of the retained one was tried and
dropped: it needs an ordering between same-source states that the rows
do not record, and each version reopened a withdrawn reading somewhere.

## Tests

- `TestPhysicalDiskMergePairsTemperatureWithItsCollectionState`, in both
ingest orders:
- silent agent against a collected Proxmox reading, and against no
Proxmox reading
  - reporting agent
- silent agent against a Proxmox copy of its earlier reading, with and
without SMART attributes
  - resumed agent against a Proxmox copy of its withdrawal
  - standby agent against a Proxmox copy of its pre-sleep reading
- `TestPhysicalDiskMergeNeverPresentsAWithdrawnTemperatureAsCollected`:
three distinct-valued withdrawn or stale rows in all six orders.
- `TestUnraidInventoryRowDoesNotMaskTheSMARTRowState`: the Unraid
inventory's `available` no longer lends itself to a SMART row's
uncollected reading.
- Without the rule, the peer-reported case, the Unraid masking case and
one three-way order fail.
2026-10-06 23:05:53 +01:00
rcourtman dcb69cfb02 Record Docker host, service and sub-resource alert history (#2620)
Follows #2608, which made `MonitorAdapter.RecordChange` resolve Proxmox
and agent alert references to canonical resources. Three kinds of alert
reference still journaled under strings no resource answers to:

- Docker host alerts (`docker:<host ID>`)
- Swarm service alerts (`docker:<host ID>/service/<ID>`)
- sub-resource alerts such as ZFS pool and device health (`<storage
ID>/zfs-pool:<pool>[/device:<dev>]`) and host filesystem, SMART, RAID
and sensor alerts (`agent:<host ID>/disk:…` etc.)

Resource facets, timelines, resource intelligence and Assistant context
read history by canonical ID, so these alerts never appeared there.

## What changes

- **Docker host refs** resolve through the registry's Docker source ID.
- **Swarm service refs** resolve through the host's Swarm cluster to the
service with that exact ID, from any manager that reports it. A service
with no ID is referenced by its name in the same slot, so while a
cluster has such a service, every service ref there is a conflict and
binds nothing.
- **Never bind:** container names, shortened container IDs and hostless
`docker-service:<name>` refs never bind, and are no longer retried
against later inventory.
- **Sub-resource refs** bind to the owner whose durable ID they carry:
  - ZFS pool and device alerts go to the storage.
  - The Unraid array alert goes to its array storage.
- Filesystem, SMART, disk temperature, RAID and custom-sensor alerts go
to the host.
- The owner must resolve to exactly one resource of the expected type.
An ambiguous or incompatible owner is a conflict.
- Mount and kernel device labels can name a different disk after a
reboot, so these alerts never bind a physical disk.
- **Retries:** a reference that hits a conflict at write time now enters
the backfill retry set, the same as one found at startup.

## Known residuals (producer-side, follow-up proposed)

- A container with no ID alerts under its host's reference
(`DockerResourceID(host, "")`), so its events join that host's history
(never another container's).
- An ID-less service whose name equals another service's ID shares that
service's reference. Rows it journaled can join the other service's
history once a later generation or event binds the reference. Swarm
always reports service IDs, so this needs a malformed report.

Both are documented in the contract. Separating them needs a producer
change in `internal/alerts`.

## Evidence

Private `PULSE_MOCK_MODE` stack, fresh data dir. The binary at #2608's
tip ran first, then this change on the same dir. Alert rows were
classified by source reference (from `alert_identifier`) and checked
against each owner's `/api/resources/{id}/timeline`:

| ref shape | rows readable via owner timeline, before | after |
|---|---|---|
| Docker host | 0 / 1 | 3 / 3 |
| Swarm service | 0 / 5 | 19 / 19 |
| ZFS pool | 0 / 9 | 26 / 26 |
| ZFS device | 0 / 9 | 27 / 27 |
| agent filesystem | 0 / 1 | 3 / 3 |
| Docker container (mock short IDs) | 0 / 11 | 0 / 30, no container
alias written |

Tests:
-
`TestHistoryIdentityMonitorAdapterResolvesDockerHostAndServiceReferences`
covers multi-manager services, a Docker host merged into its agent
machine, legacy-row backfill, recovery after removal, ID-less service
conflicts, and names and short IDs staying unbound.
- `TestHistoryIdentityMonitorAdapterResolvesSubResourceReferences`
covers every owner shape, wrong-type and ambiguous owners, retained
bindings, write-time conflict retry, and physical disks staying unbound.
- `TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` drives the real
alert manager for Docker host offline, Swarm service gap, ZFS
pool/device and host filesystem alerts. It fails on #2608's tip.

UI reach: the Docker, storage and platform host-row drawers don't
request per-resource history. This fix reaches the
facets/timeline/intelligence APIs and Assistant resource context, not a
drawer.
2026-10-06 22:57:41 +01:00