Commit Graph

5950 Commits

Author SHA1 Message Date
rcourtman 112ce030d7 Stop reports and resource history calling a moved alert recovered
A node alert that moves to its linked Pulse agent closes with a
moved_to_agent resolution (#2580), and #2591 taught Patrol, alert
history and the assistant to say so. Two readers still showed the
close as a recovery.

Performance reports dropped the resolution when copying recently
resolved alerts into reporting.AlertInfo. A node report generated in
the five-minute window after a handover read HEALTHY, "All systems
operating normally", counted the alert as triggered and resolved, and
told the AI narrator resolved: true, although nothing had seen the
condition clear. Report rows now carry the resolution and the alert's
resource. pkg/reporting treats an alert that closed without recovering
as still live unless the report also shows its successor's reading for
that metric (an open alert, or a recovery since the handover), so a
handover never reads as healthy and one condition is counted once;
fleet reports match handovers across all their resources. The PDF
card, Quick Stats, active list and heuristic narrative say where the
alert went and to check the agent, the alerts table shows "Moved" with
the engine's summary, fleets count it as active and name the agent,
and the narrator gets resolved: false, the summary and whether the
successor's alert is listed, with prompt rules for both.

Resource change readers labelled every alert_resolved change "Alert
resolved" in green and prefixed the headline with it, so a moved close
read "Alert resolved: Alert moved to pve1 (Host Agent). This is not a
recovery...". getResourceChangePresentation now reads the
alert_resolution metadata: such a change is "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.

Reports requested as agent, which is how the Reports page addresses
every Proxmox node, still carry no alert rows at all, and node alert
history is stored under legacy node IDs that the drawer never queries;
both are separate fixes.
2026-10-06 20:11:04 +01:00
rcourtman d26a483731 Show a moved alert as moved in Patrol, history and assistant (#2591)
Follow-up to rcourtman/Pulse#2580. A node alert handed to its Pulse
agent closes with an `AlertResolution` (`moved_to_agent`), and
notifications already say it moved. Every in-app reader still treated
that close as a recovery:

- Patrol's attention detail showed a green **Resolved** badge, and its
timeline read "Open to Resolved · Recovery evidence" directly above
"…This is not a recovery…".
- The Alerts history listed the row as **resolved**, and its incident
timeline ended "Alert resolved: Node memory at 95.0%".
- `GetRecentlyResolved` rebuilt `models.Alert` without the resolution.
So the state snapshot, websocket, assistant context, Patrol's seed
prompt and the `pulse_alerts` `resolved` action all saw a plain resolve.

**Operational trust.** New `TransitionCause` `ownership_transferred`. It
may only enter `resolved`, and only with evidence.
`markOperationalResolved` uses it whenever the alert carries a
`moved_to_agent` resolution; every other close stays on
`recovery_evidence`. Alert-quality telemetry keys "resolved while
snoozed" on `recovery_evidence`, so a handover during a snooze no longer
counts there. No notification code reads the cause: links key on the
transition ID and state.

**State and AI.** `models.Alert.Resolution` holds the reason, successor
and the engine's `Summary()`. `cloneAlert` copies it, and
`GetRecentlyResolved` fills it.
- Patrol's seed writes `closed 3m ago. Alert moved to pve1 (Host Agent).
This is not a recovery: …`.
- The assistant's context writes `closed … ago) <summary>`.
- The resolved-alerts tool returns `resolution` and
`successor_resource_id`.

**Timelines.** The `alert_resolved` resource change carries the summary
as its reason and the code as `alert_resolution` metadata. Incident
memory titles the close with it, both when projecting from the canonical
timeline and on its own write path.

**UI.**
- The history row reads **moved to agent**, even if it was acknowledged.
The full account is in the badge title and is printed under the message
on the phone card.
- Patrol's detail shows a **Moved** badge (info tone), with the handover
sentence directly under the summary rather than only inside Evidence and
history. Its timeline says "Ownership transferred".

**Landing order.** rcourtman/Pulse#2586 is the only producer of a
handover, and it has no auto-merge. It should land after this PR (or
merge main once this is in). Any handover recorded before this lands
would otherwise keep a recovery-labelled transition and timeline.

**Not covered here:**
- Performance reports still list a handover from the last 5 minutes as
resolved.
- The resource drawer labels the change "Alert resolved" before its
reason.
- The incident header in the history expansion still shows its
open/resolved status word.

Both of the first two are filed as a follow-up and noted in
ai-runtime.md and unified-resources.md.

**Tests:**
- `TestOwnershipTransferOnlyClosesWithEvidence`.
- The cause assertions in
`TestReleasedMetricAlertCarriesResolutionInsteadOfRecovery`.
- `TestRecentlyResolvedCarriesHandoverResolution`.
- `TestAlertResolutionWireContract`, which fails without the
`cloneAlert` copy.
- `TestMonitor_HandleAlertLifecycle_HandoverCloseIsNotARecovery`, which
fails on the old code with "Alert resolved: Memory usage at 95%".
- `TestIncidentStore_HandoverCloseIsNotARecovery`.
- `TestSeedHealthAndAlerts_HandoverIsNotARecovery`.
- `TestService_buildAlertContext_HandoverIsNotARecovery`.
- `TestExecuteListResolvedAlertsReportsHandover`.
- Frontend history, presentation and Patrol workbench tests.

The operationaltrust, alerts, models, ai, ai/tools, ai/memory,
unifiedresources, monitoring and notifications suites pass. `tsc`,
eslint and the vitest sweep pass, apart from the Mac-only
`agentInstallCommand` PTY test.

**Browser pass.** A private non-mock stack ran on an alert history
written by the real `releaseCanonicalMetricAlert` handover path. I
checked Alerts history (row, tooltip, expanded timeline) and Patrol's
deep-linked detail at 1280 and 375: no overflow, no console errors.
2026-10-06 19:24:36 +01:00
rcourtman 779b3d4ff8 Tint Kubernetes node rows for their drawer's alerts (#2596)
A Kubernetes node that runs a Pulse agent is an agent row, and the
agent's alerts are keyed `agent:<id>` and labelled with the hostname.
The nodes table matched only the row id and display name, so on a mock
estate "Prod Euw1 K8s 02" sat at 100% memory with a critical alert, its
drawer listed "Agent memory at 100.0%", and its row was untinted. The
overview's "Nodes needing attention" looked only at readiness and
cordons, so it named K8s 03 (Degraded) and left K8s 02 out.

- Kubernetes node rows tint from `getUnifiedResourceAlertStyles`
(#2579), the set the drawer reads, and drop the tint while expanded like
the Docker host and Machines rows.
- `getKubernetesNodeAttentionRank` ranks a node by the stronger of its
state and its unacknowledged open alerts: NotReady and critical alerts
first, then cordoned or degraded nodes and other alerts. The overview
list and the nodes table's default order both use it, so a critical node
no longer sorts below a degraded one. Acknowledging the alert drops the
node out of both, as it already drops the tint.
- vSphere host and TrueNAS system rows are deliberately unchanged.
Checked against the producers: their VMs', pools' and apps' alerts carry
the host name as their node (VM `ParentName` is the runtime host;
registry children take the parent's name), the TrueNAS drawer states the
same pool problems, and `agent:` alert ids only come from Pulse agent
reports (`internal/alerts/host.go`, `external_probe.go`). A Pulse agent
on a TrueNAS box stays a separate resource
(`TestUnifiedDiskWriteMetricsKeepSameHostnameHostAgentAndTrueNASTargetsSeparate`).
Switching them would drop the pool rollup that tints the TrueNAS row for
a degraded pool, so a test now pins it.

Verified in a browser on a private mock stack (receipt
`frontend-modern/browser-verification/83848f2d5ef2a326.json`): Nodes and
Overview at 1440px, 390px and in dark mode, the drawer, and acknowledge
then unacknowledge of the K8s 02 alert. The new Kubernetes tests fail
against the previous runtime and pass with this change.
2026-10-06 19:22:22 +01:00
rcourtman 40ebce161a Clear agent disk temperature alerts when the disk leaves (#2595)
Stacked on #2589 (the threshold-off fix). Until it merges this PR also
shows its two commits; the change here is the last commit, 7c8e9099a1.

A host-agent SMART disk temperature alert (`diskTemperature`, resource
`agent:<host>/disk_temp:<device>`) stayed active for about a day after
its disk left the agent's report (removed, replaced, or a renamed device
path). `CheckHost` only evaluates disks it still lists, and
`shouldPreserveAlertOutsideNodeCleanup` exempts `agent:` alerts from
node cleanup, so only the 24-hour stale-alert sweep resolved it.

What changes:
- `CheckHost` records every disk a non-empty SMART list reports. A disk
holding an alert or a pending threshold run that is missing from three
consecutive non-empty reports has its alert cleared and its pending run
dropped (`cleanupHostDiskTemperatureAlerts`). The count restarts when
the disk is reported again, because the Windows, FreeBSD and
controller-multiplexed Linux collectors drop a disk whose probe fails.
- An empty SMART list holds every alert. The agent omits the list when
SMART collection fails or is unsupported (`collectSMARTData`).
- A listed disk in standby, or with no temperature (a failed Linux probe
lists the disk identity-only), holds its alert. Neither reading shows
the disk cooled, and SMART health alerts already hold through standby.
- Turning the threshold off, or removing the agent, now also drops the
host's pending disk temperature runs and absence counts.

`TestCheckHostClearsDiskTemperatureAlertWhenDiskLeavesSMARTReport`
covers removal, rename, the three holds, the restarted count, and the
pending-run and count cleanup. Each case was checked against a mutation
that disables its mechanism.
2026-10-06 19:18:41 +01:00
rcourtman 4bd567b41a Clear agent disk temperature alerts when their threshold is off (#2589)
Turning off the agent disk temperature threshold left every open disk
temperature alert active indefinitely.

**Why nothing cleared them**
- `CheckHost`'s branch for a disabled `DiskTemperature` threshold was an
empty placeholder comment.
- The host-wide clears (all agent alerts disabled, a disabled host
override, agent removal, confirmed agent offline) matched only
`agent:<host>/disk:` and missed `agent:<host>/disk_temp:`.
- A config save skipped these alerts because `diskTemperature` was not
classed as a threshold metric in `metric_runtime.go`.

**Changes**
- `host.go`: `CheckHost` clears a host's disk temperature alerts when
the effective threshold is off, and `clearHostDiskAlerts` plus the
confirmed-offline sweep include the `disk_temp` prefix. The per-type
`DiskTempByType` refinement moves into
`hostDiskTemperatureThresholdNoLock`, which `CheckHost` and
`resolveHostAlertThresholdsNoLock` share. A config save therefore judges
an alert against the threshold for its disk type, not the agent default.
- `host.go`: an alert persisted before `CheckHost` recorded `diskType`
(May 2026) has no disk type until its next firing evaluation. A save
judges it against the lowest enabled trigger any of the host's disks can
use, so a save cannot resolve an alert its disk type would still fire.
- `metric_runtime.go`: `diskTemperature` is a threshold metric, so a
save resolves these alerts once the threshold is off or the reading is
below it.
- `config_runtime.go`: when there is no recovery band, the save path now
matches the evaluator. `buildCanonicalMetricSpec` drops a clear level at
or above the trigger, and the reducer keeps firing at the trigger
itself. Before this, a save resolved those readings and the next report
raised them again. This applies to every metric alert that reaches the
threshold comparison (docker-host alerts return before it); with a valid
band, behaviour is unchanged.

**Tests** (`internal/alerts/host_unraid_lifecycle_test.go`)
- `TestCheckHostClearsDiskTemperatureAlertsWhenThresholdTurnsOff`: agent
default off, host override off, host override disabled, all agents
disabled.
- `TestHostDiskTemperatureAlertsClearWhenAgentLeaves`: agent removed,
agent confirmed offline.
-
`TestConfigSaveResolvesDiskTemperatureAlertAgainstItsDiskTypeThreshold`:
an unrelated save keeps an alert above its nvme trigger; turning the
threshold off resolves it.
- `TestConfigSaveKeepsDiskTemperatureAlertWithoutDiskType`: an alert
with no `diskType`, at 72C under a 75C agent default and a 70C nvme
entry, survives an unrelated save.
- `TestConfigSaveKeepsAlertTheEvaluatorKeepsWithoutRecoveryBand`: a
70/75 per-type entry at 72C and at 70C.

Every case fails against origin/main's code (checked with a build
overlay) and passes here. `go test ./internal/alerts/...` passes.
2026-10-06 18:59:53 +01:00
rcourtman e9069aed8c Carry a typed reason when an alert closes without recovering (#2580)
When a Pulse agent is linked to a Proxmox node, the node releases its
CPU, memory and disk alerts to the agent, and any node alert still open
closes. The close must reach integrations, so a PagerDuty or webhook
incident opened from the node alert does not stay open forever. But it
went out as a recovery: "pve1 on pve1 is now healthy" for a node still
at 95% memory, backed by synthesized "recovery projection" evidence.
#1682 is the same complaint from another trigger.

This adds a typed `AlertResolution` (first reason: `moved_to_agent`,
with the agent's alert resource ID and name) and carries it from the
release path to every resolved consumer:

- `releaseCanonicalMetricAlert` is the disabled-threshold release pulled
out of `evaluateCanonicalMetricAlert`. It takes a resolution, and
`clearAlertWithResolution` stamps a copy on the alert before it
resolves.
- The resolution rides the Alert JSON into the resolved callback, the
delivery queue, the history row and the event log (reason code plus
summary).
- The synthesized closing evidence and the resolve transition say what
happened instead of claiming a recovery projection. The cause stays
`recovery_evidence`, the only cause operational trust lets enter
`resolved`.
- Notifications still send the close. Messages say "Memory alert moved
to pve1 (Host Agent). This is not a recovery: check the agent for the
current reading." in webhooks, `MOVED:` on a single ntfy push, and
"Pulse alert moved" on email and Apprise. When any close in a delivery
was not a recovery, `WebhookPayloadData.NotRecovered` drops recovery
visuals: Discord's green embed turns neutral blue, both Teams cards lose
recovery green, Mattermost's check emoji becomes an arrow, and ntfy uses
the arrow tag for a single alert or a batch. Titles still say Resolved.
PagerDuty still gets `resolve`.

Every caller passes a nil resolution today, so ordinary recoveries and
the disabled-threshold path are unchanged. The node release adopts the
reason in a follow-up, once the host-agent node link records which
metrics the agent evaluates (#2577 lane).

Tests: `TestReleasedMetricAlertCarriesResolutionInsteadOfRecovery` and
`TestResolvedHistoryRowKeepsResolutionWithoutEventLog`
(operational_contract_test.go); `resolved_handover_test.go` drives the
firing, its receipt and the queued close through the normal ntfy entry
points, and renders Discord, both Teams cards, Mattermost, PagerDuty, a
grouped Slack list, a grouped ntfy batch and email/Apprise content, with
isolated managers. The full alerts, notifications and monitoring suites
pass locally.
2026-10-06 18:57:09 +01:00
rcourtman 69979370e9 Show each identifier once in the drawer's Identity card (#2590)
From a live user-lens pass over Machines.

A Machines agent drawer's Identity card listed `agent:host-linux-1` as
its Primary ID, its Discovery target and its Metrics Target. Its Aliases
then repeated it again, along with the hostname and the Platform ID the
card had just shown: eleven values, four of them the same ID. Every
platform's drawer shares this card. A vSphere datastore repeated its ID
the same way.

**What changes**
- **Discovery and Metrics Target** rows appear only when they name
something not already on screen: the header's name
(`getPreferredInfrastructureDisplayName`, which is what the header
renders), the rows above, or the Platform ID row.
- **Aliases** leave out those same values.
- **Hidden names don't count.** Candidate names the drawer never shows
don't count as shown, so a distinct target still appears.
- **No false empty state.** A shown Platform ID counts as identity data,
so filtering can't leave a "No identity metadata yet" message beside it.
- **Display only.** Search and alias lookups keep the full lists.

**Before and after (mock stack, same drawers)**
- **Machines (Apollo-114)**
- Before: Hostname · Primary ID · Discovery · Metrics Target (the last
three identical) · Platform ID · IPs · Aliases: `agent:host-linux-1`,
`host-linux-1`, `apollo-114`, `agent-acdf…`
- After: Hostname · Primary ID · Platform ID · IPs · Aliases:
`agent-acdf…`
- **vSphere datastore (archive-tier)**
- Before: Hostname · Primary ID · Metrics Target (same as Primary ID) ·
Platform ID · Tags · Aliases: Primary ID again, two short IDs, the
hostname again, +1
- After: Hostname · Primary ID · Platform ID · Tags · Aliases: the two
short IDs

Checked live at 1440 and 390px.

**Tests:** 1,278 tests pass. Three existing tests pinned the repetition
and are updated. New tests cover:
- an identity shown once;
- a distinct target kept even when it matches a name the drawer never
shows;
- aliases filtered against the header's actual name;
- a shown Platform ID counting as data.

**Review:** a second reviewer found that my first version compared
against names the drawer never shows, used a different header-name
fallback, and could produce a false empty state. All three are fixed,
and the reviewer confirmed.
2026-10-06 18:22:12 +01:00
rcourtman 1f627a7ff3 Keep the availability result readable and give names room (#2588)
From a live user-lens pass over Machines → Availability checks.

**The result clipped.** A check observed from several locations
(Frontend Swarm service in the mock) showed "failed…" in its Result
cell, with a "2 locations · 2/2 reporting" chip spilling past the
column. The chip was given `flex` next to the badge's own `inline-flex`,
which left it inline beside the result, so the result clipped at every
width up to 1440px.

The result and chip now share one line:
- **The result** keeps its full width at the cell's right edge, where
every row's result aligns.
- **The chip** truncates to its left with its full text on hover. It now
reads "2/2 locations reporting", the wording Settings uses for the same
fact.
- **Rows stay one line** because the table windows them at a single
measured height. A second line would desynchronize scrolling on large
estates.

**Names were cut on phones.** Method and Target took the room, so check
names read "Workshop do…" and "Frontend Sw…". Both now hide on phones,
since the row drawer still shows them. The Method cell also truncates
with an ellipsis instead of clipping.

**Verification**
- **Live, private mock stack**, at 390, 600, 768, 1024, 1440 and 1920px:
  - The result never clips.
  - Rows measure 32–33px at every width.
  - Phone check names read in full, except one row that is 3px short.
- **Tests:** 3,977 tests pass across the standalone, Settings and shared
suites, including new tests for the one-line result with a
multi-location source and for the phone-hidden columns.
- **Review:** a second reviewer flagged that my first version (chip on a
second line) broke the table's uniform-height windowing. This one-line
layout fixes it, and the reviewer confirmed.
2026-10-06 18:04:19 +01:00
rcourtman 66c8c63e79 Hand a node only the usage metrics its agent evaluates (#2581)
This stacks on #2577. Once #2577 merges, the diff here reduces to the
last commit.

## Problem

`CheckHost` registered a host agent's node link before its disabled
returns. With agent alerts switched off, or a host override disabling
the agent, the node still released its CPU, memory and disk alerts to an
agent that evaluated nothing, so nothing alerted on that machine's
usage. The same happened per metric: an agent CPU threshold set to off
with the node CPU threshold on left the machine with no CPU alert. The
gap predates #2569; the old hostname dedup registered in the same place.

## Fix

- The link now records which usage metrics the agent actually evaluated
on its last report: a live CPU threshold, a live memory threshold with a
known reading, and at least one filesystem with a live disk threshold.
- `CheckHost` registers the link after evaluation and removes it on the
disabled returns.
- `CheckNode` releases only the metrics the agent owns and evaluates the
rest itself. Deduplication can only remove a duplicate, never the last
alert for a machine.
- The link also carries the agent's display name and a stable lookup
(`linkedHostAgentForNode`, lowest agent ID wins). The handover
notification work builds on these.

## Tests

`TestCheckNodeKeepsUsageMetricsTheAgentDoesNotEvaluate` covers two
cases: agent alerts disabled (the node alerts on CPU and memory), and
the agent CPU threshold off (the node keeps CPU while the agent keeps
memory, so the machine still has exactly one memory alert). Both cases
fail with the old registration order. The dedup tests use the new link
struct. `go test ./internal/alerts/...` passes, and so do the monitoring
alert, temperature, host-agent and node tests.

## Review follow-up (second commit)

A review of the first commit found three more ways the node and its
linked agent could disagree about who alerts:

- **Memory ownership flipped with each report.** Ownership needed a
known memory reading on every report. When the reading went missing
while the agent's alert was open, the agent kept its alert but stopped
owning memory, so the node raised a second alert. That alert resolved as
soon as the reading came back. Ownership now follows the agent's
configured thresholds, and a missing reading is treated as missing.
- **Only one linked agent counted.** With several agents linked to one
node, only the lowest agent ID's coverage was used. A metric is now
owned when any linked agent evaluates it.
- **Disk ownership covered the wrong filesystem.** A linked node's disk
metric is the agent's summary filesystem: root when reported
(`models.SummaryDisk`). Any evaluated mount was enough to take
ownership, so with root disabled by a per-disk override and `/data`
still alerting, a full root filesystem raised nothing. The agent now
owns the node's disk metric only while it evaluates that same
filesystem.

`TestCheckNodeUsageOwnershipFollowsWhatAgentsEvaluate` covers all three
cases; each fails without its fix.
2026-10-06 17:50:49 +01:00
rcourtman f419149458 Evaluate host agents in mock mode like live agents (#2585)
## Why

`checkMockAlerts` evaluated mock nodes, guests, storage, PBS, PMG and
Docker hosts but never host agents. Live agents are evaluated by
`CheckHost` as each report lands; mock mode discards reports and the
host health sweep never sees fixture agents, so no agent CPU, memory,
disk, temperature or offline alert could open against mock data. A mock
agent at 94% memory sat under the 85% default trigger with no alert, and
any alert work on agents needed a private overlay build to reproduce.

## What

Mock passes evaluate every fixture agent (`evaluateMockHostAgents`) with
the live lifecycle boundaries:

- Offline fixtures go through `HandleHostOfflineWithCorrelation`,
because `CheckHost` treats its input as a fresh report and marks the
host online.
- Agents run before nodes, so a node with an online linked agent hands
its CPU/memory/disk alerts to the agent on the first tick (host-agent
hostname deduplication).
- An agent that leaves the estate on a runtime mock config change goes
through `HandleHostRemoved`, like a deleted live agent.
- Leaving mock mode routes the fixture agents through
`HandleHostRemoved`, releasing hostname registrations that
`ClearActiveAlerts` leaves behind (they would otherwise suppress a real
node named `pve1`). A pass already in flight finds mock mode off under
the same lock and evaluates nothing.

Evaluating agents exposed a mock-only reading: each Kubernetes pod took
a whole single-pod footprint (10-83%) of its node's allocatable memory,
so any node running four or more pods, and its linked agent, read a
constant 100%. On the public demo's estate that became two permanent
critical agent memory alerts. Pods now share 0.7 footprints per node (at
most 58% in steady state), and a 60% per-node ceiling clips the
transient while rescheduled pods' readings settle, so k8s nodes stay
under 82%.

Contract notes: `monitoring.md`, `performance-and-scalability.md`,
`agent-lifecycle.md` (the `monitor.go` field touches only the alert-side
removal boundary, with no tombstone, token or continuity change).

## Demo impact

Measured on private stacks with the demo host's exact `PULSE_MOCK_*`
estate (8 nodes, 2 standalone agents, 3 Kubernetes node agents):

| | before | after |
|---|---|---|
| Kubernetes node agents' memory | 100%, 100% | 19-50% (up to ~76%
briefly after boot) |
| Agent alerts at default thresholds | none, ever | per-boot readings
decide: one boot opened none, another opened Apollo-114 memory 91.3%
(warning) |

## Verification

- New tests, each shown to fail on the code it guards (via `go build
-overlay`): `TestCheckMockAlertsEvaluatesHostAgentsBeforeLinkedNodes`
(also fails with the agent loop after the node loop),
`TestCheckMockHostAlertsUsesHostLifecycleForOfflineFixtures`,
`TestLeavingMockModeReleasesFixtureAgentHostnames`,
`TestMockHostAgentPassAfterLeavingMockModeRegistersNothing`,
`TestMockHostAgentLeavingFixtureUsesRemovalLifecycle`,
`TestMockKubernetesDenseNodesStayBelowAllocatableMemory`,
`TestMockKubernetesRescheduledPodsDoNotSaturateReceivingNode`,
`TestMockKubernetesLoneHeavyPodStaysUnderNodeCeiling`.
- `go test ./internal/mock/ ./internal/monitoring/` pass.
- Live, private demo-config stack: with the agent memory trigger at 60%,
exactly the agents above it opened `agent:` memory alerts and their
linked Proxmox nodes raised none; the Alerts page renders them.
Shrinking the estate from 8 to 4 nodes through `/api/system/mock-mode`
cleared the departed agents' alerts on the next pass.
- Three rounds of independent review; the first two found the
stale-registration, removal, relocation and steady-state ceiling issues
fixed here, the third found none.
2026-10-06 17:38:10 +01:00
rcourtman 0a55939c72 Keep open row drawers spanning the table when columns change (#2571)
An open inline row drawer measured its colspan from the summary row's
visible cells only on mount and on resize. Hiding or showing a column
with the drawer open, from View > Columns or through a data-driven
column such as the vCenter column added in #2566, left the drawer with
its old span.

Reproduced on a mock stack before the change:
- **Machines (`/standalone`), 1440px**: hiding Uptime with a drawer open
left colspan 9 against 8 headers. The header and rows shrank to 939px of
the 1375px table, beside a blank 436px column. Showing System left
colspan 7 against 8, and the drawer stopped 79px short of the actions
column.
- **Proxmox guests (`/proxmox`), 1440/1024/768px**: hiding Type, Uptime
or Backup left colspan one above the header count. The fixed layout
collapses the phantom column to about 1px there.

Fix, in the shared `InlineDetailTableRow`:
- A MutationObserver on the summary row re-measures when cells are
added, removed or hidden (class, style, hidden or colspan changes on a
cell). Changes inside a cell's content are ignored, so live metric
updates do not re-measure.
- The span also re-syncs when the caller's requested colspan changes,
which covers rows with no summary row to measure.
- When the span grows, Chromium keeps a fixed-layout table's old column
widths for the widened cell (minimal repro: 400px instead of 600px after
a column is added and laid out). The row sets the cell's own width for
one forced layout and restores it. Focus and inner scroll positions
survive.

After the change, on both tables at 1440 and 768px, the drawer colspan
equals the visible header count after every toggle. The drawer, the last
header cell and the last summary cell all end at the table edge, and the
drawer row stays mounted. The vSphere Activity drawer still spans 6 of 6
columns.

`InlineDetailTableRow.test.tsx` gains three tests that fail on main:
removing and restoring a summary cell, hiding one in place, and changing
the requested span with no summary row. It also becomes a registered
frontend-primitives proof. The contract note sits beside the existing
`InlineDetailTableRow` clause in `frontend-primitives.md`.

Follow-up commits on this branch:
- `InlineDetailTableRow.test.tsx` spies on `offsetWidth`. It requires
exactly one forced layout read of the detail cell, at width 0px with the
new colspan, when the requested span and the summary cells grow in the
same update, and none when they shrink. A further test checks that a
summary cell's colspan change re-measures while class and node churn
inside a cell does not. Removing the forced read, doubling the
workaround, removing it, or widening the observer filter each fails the
matching test.
- `tests/integration/tests/98-inline-drawer-column-span.spec.ts` is a
real-browser check on the Machines table. It opens a drawer, hides and
restores System through View > Columns, and asserts the following after
each step: the drawer spans the visible headers, the drawer, last header
and table share a right edge, and the drawer row, focus and any inner
scroll survive. It fails on main before the fix (colspan 8 against 7)
and with only the width workaround removed (drawer 79px short). It
passed five runs in a row with the fix.
2026-10-06 17:37:34 +01:00
rcourtman ee3ebd800e Stop presenting a silent agent's node temperature as live (#2582)
A host agent linked to a Proxmox node merges with it into one read-state
row, and every PVE poll refreshes that row's `LastSeen`. The agent's
last sensors stay in state after it stops reporting. The node
temperature lookup (`getHostAgentTemperatureByID`) checked neither, so
each poll merged the silent agent's last reading into the node as a
fresh measurement, stamped with the row's fresh `LastSeen`:

- an open node temperature alert was re-observed every poll, so its
`LastSeen` kept moving and the 24h stale-alert cleanup never reached it;
a stale hot reading could keep activating
- node history recorded the frozen value as new samples, and the UI and
reports showed it as current
- SSH collection was skipped as if a recent agent payload existed

Since #2569 evaluates node temperature for agent-backed nodes, this
holds their temperature alerts open.

## Change

- `HostView.SourceStatus(source)` exposes the per-source freshness the
registry already records (same accessor the VM, container and
physical-disk views have).
- The linked-agent lookup reads the agent source's own sighting, uses
the agent's sensors only inside the reporting lease that keeps the agent
online (`hostAgentHealthWindow`, as `evaluateHostAgents` uses), and
stamps the reading with the agent's report time. Past the lease the node
has no agent reading (the cluster sensor cache is still consulted), and
alert evaluation treats that as a missing reading, not as evidence the
node cooled down.
- The poller's carry-over of a previous reading after a failed
collection is bounded to max(5 min, 2 × PVE polling interval) and keeps
its original timestamp. This branch does not engage today: the
previous-node projection carries the unified resource id and no
temperature, so it never matches the polled node. That is filed
separately; the bound keeps the carry honest once it's repaired.

The owner is the poller rather than `checkNodeTemperature`: the same
stale value also reaches history, reports and the UI. An alerts-side
`LastUpdate` check would also have missed this case, because the reading
was stamped with the merged row's fresh `LastSeen`. Mock fixtures are
untouched.

## Tests

- `TestSilentLinkedAgentStopsRefreshingNodeTemperatureAlert` (registered
monitoring proof): registry-backed merged agent+node row through the
production previous-node projection and `CheckNode`. A reporting agent
opens the alert. A silent agent leaves the alert open but stops
refreshing `LastSeen`. A resumed agent is evaluated again. It fails on
origin/main.
- `TestView_HostViewSourceStatusSeparatesAgentFromMergedRow`: a merged
row's `LastSeen` follows PVE, while `SourceStatus(SourceAgent)` keeps
the agent's own report time.
- `TestCollectNodeTemperatureCarryIsBoundedByCarryWindow`,
`TestHostAgentReportCurrent`,
`TestGetHostAgentTemperatureByID_IgnoresAgentPastReportingLease`.

Out of scope, filed separately: `mergeHostAgentSMARTIntoDisks` copies
silent agents' retained SMART temperatures into physical disks.
2026-10-06 17:31:31 +01:00
rcourtman c4bef05354 Give agent disk, array and sensor alerts their own resource type (#2583)
## Why

Alerts a host agent raises about something other than its own machine
(filesystems, disk temperatures, SMART disks, RAID and Unraid arrays,
custom sensors) carried the host's `resourceType: agent`. The alert
card's Monitoring menu writes policy to the alert's child resource id
(for example `agent:<host>/disk:sda`), yet it named the Pulse agent as
the inventory owner, which is the copy meant for the agent's own
machine. Follow-up to #2574, whose card logic gives agent-platform
children the neutral copy once their producers say what they are.

## What

- `internal/alerts/host.go`: every host alert stamps `platformType:
agent`. Child alerts carry `agent-disk` (filesystem usage, disk
temperature, SMART health and wearout), `agent-storage` (RAID, Unraid
array) or `agent-sensor` (custom sensors). The machine's own CPU, memory
and offline alerts keep `agent`.
- `internal/alerts/config/identity.go`: `agent-storage` and
`agent-sensor` keep `agent` among their policy keys, as `agent-disk`
already did, so agent thresholds and switches still govern them.
- `internal/alerts/config_runtime.go`: configuration re-evaluation stops
at the agent branch for host-agent alerts. Before, a SMART, RAID or
sensor alert fell through to the guest switch, so disabling guest alerts
resolved it on save and the next agent report raised and notified it
again.
- `internal/alerts/canonical_metric.go`: a metric alert held between its
clear and trigger thresholds now refreshes `resourceType` and
`platformType` from its producer, so an alert restored from a checkpoint
written before these keys existed stops linking to the wrong page.
Value, message and last-seen time still stay at the last breach.

Webhook payloads report the new `resource_type` values for these alerts.

## Verification

- `TestHostChildAlertsNameTheirOwnResourceType`,
`TestHostChildAlertsIgnoreOtherPlatformSwitchesOnConfigSave` and
`TestAlertCharacterizationHeldMetricAlertRefreshesItsClassification`
fail on the previous code and pass now.
`TestHostChildAlertsSurviveProxmoxNodeCleanup` guards the Proxmox node
sweep, which keeps these alerts by their `agent:` resource id. `go test
./internal/alerts/...` passes.
- Private mock stack, with before and after binaries on one data
directory. A local-only overlay gave mock agents SMART, RAID, Unraid and
custom-sensor readings and ran `CheckHost`. Before: every child alert's
Monitoring menu read "Pulse agent remains the inventory owner". After:
the host's memory alert still does, while the filesystem, SMART, RAID,
sensor and Unraid alerts read "Source system remains the inventory
owner", and all still link to `/standalone/machines`. Muting from the
SMART card wrote operator state for `agent:host-linux-1/disk:sda` and
left `agent:host-linux-1` unconfigured.
- Restore path: a filesystem alert fired at 60%, held at 40%, and was
saved by the old binary as `agent` with no platform. The new binary
restored it, and after one evaluation it read `agent-disk` / `agent`,
with its value, `lastSeen` and `diskUsagePercent` unchanged.
2026-10-06 17:30:19 +01:00
rcourtman f9ce06123a Say what an agent update brings and what the CPU count is (#2584)
Two small user-lens fixes from a live pass over Machines.

**Outdated-agent notice wording**
Machines told users to update "for the latest agent command support and
agent-managed platform detail". Proxmox, TrueNAS and vSphere used
similar internal phrasing: "agent-contributed Proxmox node detail and
command support", "agent-contributed TrueNAS system detail and command
support" and "in-guest telemetry and command support".

All four now read "Update them to v4.26.0 for the latest fixes and
machine details", or node, system or VM details.

The notice does not promise Patrol actions or command support. It
selects hosts by agent version alone, and command execution is a
separate setting that the upgrade command preserves, so an update
neither grants nor removes it.

**CPU count**
A machine's CPU cell reads "22% (18)", and nothing explained the 18: the
hover panel listed only Usage and Load (1m). The shared CPU bar's hover
panel now leads with "CPUs 18". The number is logical CPUs on a host and
configured vCPUs on a guest, so "Cores" would be wrong on hyperthreaded
machines.

**Verification**
- **Live, private mock stack:**
- The Machines and Proxmox notices read the new wording. TrueNAS and
vSphere have no outdated agents in the mock, so the updated page
contract tests cover them.
  - Hovering a machine's CPU bar shows "CPUs 18" above Usage and Load.
- **Tests:** 1,236 tests pass across the touched surfaces, including two
new hover tests (row present, row omitted when the count is unknown).
- **Review:** a second reviewer caught that "Patrol actions" overclaimed
what an update brings, and that "Cores" mislabels logical CPUs and
vCPUs. Both are fixed.
2026-10-06 17:20:35 +01:00
rcourtman bffd21f470 Keep row, drawer and replication ages moving on the shared clock (#2578)
Follow-up to #2573, which added the shared relative-time clock
(`useRelativeTimeNow`) and put `PlatformTableRelativeTimeValue` cells
and the platform alert tables on it. Several other ages were still
computed once at render. Rows and open drawers reconcile in place, so
those ages froze whenever the timestamp stopped changing. The frozen
cases are the ones that matter: a silent agent, a completed Job, or a
stalled replication scheduler.

Now on the shared clock:
- **Kubernetes controllers**: Job and CronJob Detail ages, plus the
drawer's controller section annotation
(`buildKubernetesControllerSection` takes `now`).
- **Resource drawer**: container Created, Started and Finished rows,
Last seen, Docker update-check age and Mail Gateway updated age.
- **Proxmox backups**: full and compact age text, and the
current/aging/stale band. The band never measures from earlier than the
wall clock, so a backup that finished inside the clock's last tick can't
band as an unknown, future age.
- **Proxmox replication**: compact Last sync and Next sync, the expanded
Last sync, and the Next sync countdown, which now turns overdue on its
own.
- **Machines**: identity subtitle (`seen ...` while the Last seen column
is hidden) and the stale-agent Last report tooltip.

Replication jobs were fetched once per Proxmox page mount. With moving
ages, that snapshot would have aged a job that keeps syncing and counted
it overdue. `ProxmoxPageSurface` now reads `/api/replication/jobs`
through `createNonSuspendingQuery` with a 30-second background poll. A
`createResource` refetch would have flashed the app-level Suspense
fallback. A failed read keeps the last jobs and the tab, and a direct
replication link holds until the first read resolves.

Tests: one fake-timer test per surface shows the age advancing with no
data change. Each of the 8 behaviour tests fails with the runtime files
reverted. A ninth test covers the clock-trailing case for backup bands,
and a surface test covers the replication background poll.

Live check: a private mock stack with Playwright `page.clock`, `/ws`
held after first render and REST reads replayed, then the clock jumped 3
hours (7 days for backups). Results:
- Kubernetes: "Completed 48m ago" → "3h ago"
- Drawer: "(48m ago)" → "(3h ago)", Last seen "1s ago" → "3 hours ago"
- Replication: "in 8m" → "2h overdue"; phone "21m"/"8m" → "3h"/"-2h"
- Backups: "1d" green → "8d" amber
- Machines: "seen just now" → "seen 3h ago"

The same run with the runtime files reverted stayed frozen. The browser
receipt is in the commit.
2026-10-06 16:44:55 +01:00
rcourtman a649dd9b45 Tint Kubernetes node rows for their drawer's alerts
A Kubernetes node that runs a Pulse agent is an agent row, and the
agent's alerts are keyed "agent:<id>" and labelled with the hostname.
The nodes table matched only the row id and display name, so a node at
100% memory with a critical alert stayed untinted while its drawer
listed the alert. The rows now use getUnifiedResourceAlertStyles, the
set the drawer reads, and drop the tint while expanded like the Docker
host and Machines rows.

The overview's "Nodes needing attention" looked only at readiness and
cordons, so that node was missing from the list too. It now ranks each
node by the stronger of its state and its unacknowledged open alerts,
NotReady and critical alerts first, and the nodes table's default
order uses the same rank so a critical node no longer sorts below a
degraded one. An acknowledged alert drops out of both, as it does from
the tint.

vSphere host and TrueNAS system rows keep the id-plus-host-name match.
Their VMs', pools' and apps' alerts carry the host name as their node,
the TrueNAS drawer states the same pool problems, and no producer keys
alerts on those rows' "agent:" aliases (a Pulse agent on a TrueNAS box
stays a separate row). A test now pins the TrueNAS pool rollup.
2026-10-06 16:43:14 +01:00
rcourtman 7c8e9099a1 Clear agent disk temperature alerts when the disk leaves
A host-agent SMART disk temperature alert stayed active for a day once
its disk left the agent's report (removed, replaced, or a renamed device
path). CheckHost only evaluated disks still listed and node cleanup
exempts agent: alerts, so only the 24-hour stale sweep resolved the
alert or dropped a pending threshold run.

CheckHost now tracks which disks a non-empty SMART list reports. Once a
disk with an alert or a pending threshold run has been missing from
three consecutive non-empty reports, the alert clears and the pending
run is dropped. The Windows, FreeBSD and controller-multiplexed Linux
collectors drop a disk whose probe fails, so a single omission does not
count as the disk leaving, and the count restarts when the disk is
reported again.

An empty SMART list holds every alert, because the agent omits the list
when collection fails or is unsupported. A listed disk in standby or
without a temperature also holds its alert. Neither shows the disk
cooled, and SMART health alerts already hold through standby. Turning
the threshold off or removing the agent now drops pending runs and
absence counts as well as alerts. Cleanup matches the disk_temp resource
prefix alone, which also catches alerts raised under the original
disk_temperature type.
2026-10-06 16:39:29 +01:00
rcourtman 3b290640a5 Tint Docker host and Machines rows for their drawer's alerts (#2579)
Follow-up to #2575. The Docker host and Machines drawers list a
resource's open alerts through `getAlertsForUnifiedResource`, but their
table rows matched alerts differently, so a row could look healthy while
its drawer said "Needs attention".

- **Docker hosts:** rows called `getAlertStyles(host.id, …, host.name)`.
Docker alerts are keyed `docker:<host source id>[/…]` and carry the
hostname as their node, so a host whose display name differs from its
hostname was never highlighted. Example on the mock estate: "Ops
Services 01" (ops-services-01, source orion-2-mock) had three open
service-health alerts and no highlight.
- **Machines:** rows had no alert highlighting, so a machine with an
open `agent:<id>` memory alert looked normal until its drawer was
opened.

`getUnifiedResourceAlertStyles` computes the existing row styles from
the same set the drawer lists. Both tables now use it with the
red/yellow tint the other platform host rows use: unacknowledged alerts
only, and not on an expanded row. `getAlertStyles` keeps its
id-plus-node matching for the tables whose alerts are keyed that way.

**Verification**
- Unit tests: the helper with Docker, agent, acknowledged-only and
detection-off cases. Docker host rows are checked against their own
drawer. Machines rows follow the live alert store through acknowledge,
escalation and resolution. Both table tests fail on the previous table
code.
- Private mock stack (embedded build of this tree): every Docker host
and machine row tinted exactly when its drawer lists an unacknowledged
alert, at 1440px and 390px in light and dark. Acknowledging Apollo-114's
memory alert from the Alerts page clears its row tint while the drawer
keeps it as Acknowledged. Receipt:
`frontend-modern/browser-verification/18ad64507908105c.json`.
2026-10-06 16:39:18 +01:00
rcourtman 6413cda171 Judge legacy disk temperature alerts by the lowest trigger on save
A disk temperature alert persisted before CheckHost recorded its disk
type has no diskType metadata until its next firing evaluation. A config
save in that window judged it against the agent threshold. With a 75C
agent default and a 70C nvme entry, a 72C alert was resolved on save and
raised again by the next report.

Without a known disk type, a save now judges the alert against the
lowest enabled trigger the host's disks can use: the agent threshold or
any DiskTempByType entry. A save therefore cannot resolve an alert its
disk type would still fire, and turning the threshold off still
resolves it.
2026-10-06 16:36:25 +01:00
rcourtman 91575a285e Merge remote-tracking branch 'origin/fix/alert-handover-resolution' into fix/moved-alert-in-app
# Conflicts:
#	docs/release-control/v6/internal/subsystems/notifications.md
2026-10-06 16:30:33 +01:00
rcourtman 47e6927819 Merge remote-tracking branch 'origin/main' into fix/alert-handover-resolution 2026-10-06 16:22:02 +01:00
rcourtman a89c08a59e Drop recovery colours and check marks from moved alert closes
A second review found the moved-alert text was honest but the visuals
still said recovered: Discord kept its green embed, both Teams cards
kept recovery green, Mattermost led with a check emoji, and a grouped
ntfy batch holding a moved alert kept the green check tag. A check mark
on a phone is read before the sentence under it.

Resolved webhook data now carries NotRecovered when any close in the
delivery was not a recovery. The built-in templates switch to neutral
colours and an arrow when it is set, and ntfy uses the arrow tag for a
single alert or a batch. Titles still say Resolved, which stays true
for the closed alert, and ordinary recoveries keep their green.

The render tests now use an isolated data directory, stop their
managers, and hand the captured request over a channel, so they no
longer touch a shared queue or race on the captured body.
2026-10-06 16:17:57 +01:00
rcourtman 23f603da9f Show a moved alert as moved in Patrol, history and assistant
A node alert handed to its Pulse agent closes with an AlertResolution
(#2580), and notifications already say it moved. Every in-app reader
still treated the close as a recovery: Patrol's detail wore a green
Resolved badge over a timeline labelled "Recovery evidence", the alert
history listed the row as resolved and its incident timeline said
"Alert resolved: Node memory at 95%", and the assistant, Patrol's seed
prompt and the pulse_alerts tool saw a plain resolve because
GetRecentlyResolved dropped the resolution.

Operational trust gains a non-recovery cause, ownership_transferred,
which may only enter resolved and only with evidence. The resolve
transition uses it whenever the alert carries a moved_to_agent
resolution, so a handover during a snooze also stops counting as
resolved while snoozed.

models.Alert gains Resolution (reason, successor and the engine's
summary), copied by cloneAlert and filled by GetRecentlyResolved, so
the state snapshot, websocket, assistant context, Patrol seed and the
resolved-alerts tool all report where the alert went. The resolved
resource change carries the summary as its reason with alert_resolution
metadata, and incident memory titles the close with it.

In the UI, the history row reads "moved to agent" with the account in
its title and on the phone card, and Patrol's detail shows a Moved
badge with the handover sentence under the summary.
2026-10-06 16:17:15 +01:00
rcourtman 982a6f6148 Merge remote-tracking branch 'origin/main' into fix/moved-alert-in-app 2026-10-06 16:05:51 +01:00
rcourtman 36b2e5fa0a Clear agent disk temperature alerts when their threshold is off
Turning off the agent disk temperature threshold left every open disk
temperature alert active indefinitely. CheckHost's disabled branch was
an empty placeholder, the host-wide clear paths (all agent alerts
disabled, a disabled host override, agent removal, confirmed offline)
matched only the /disk: prefix and missed /disk_temp:, and a config save
skipped these alerts because diskTemperature was not classed as a
threshold metric.

CheckHost now clears a host's disk temperature alerts when the effective
threshold is off, and the host-wide clears include them. A config save
resolves them against the same threshold CheckHost evaluates, including
the per-type DiskTempByType refinement. That way an unrelated save does
not resolve an alert above its disk type's trigger only for the next
report to raise it again.

The save path now also matches the evaluator when there is no recovery
band. buildCanonicalMetricSpec drops a clear level at or above the
trigger, and the reducer keeps firing at the trigger itself, but
reevaluateActiveAlertsLocked resolved any reading at or below that clear
level. A per-type entry such as 70/75 (normalization keeps it) resolved
a 72C alert on save, and the next report raised it again. With no
recovery band, a reading exactly at the trigger was resolved the same
way.
2026-10-06 16:05:50 +01:00
rcourtman 4884052bc5 Show each identifier once in the drawer's Identity card
A Machines agent drawer listed agent:host-linux-1 as its Primary ID, its
Discovery target and its Metrics Target, then again in Aliases next to
the hostname and Platform ID it had just shown. Every platform's drawer
shares this card, and a vSphere datastore repeated its ID the same way.

Discovery and Metrics Target rows now appear only when they name
something not already on screen: the header's name, the rows above,
or the Platform ID row. Aliases leave out those same values. Names the
drawer never shows do not count, so a distinct target still appears.
A shown Platform ID counts as identity data, so filtering cannot leave
a false "No identity metadata yet". Search and alias lookups keep the
full lists; only the card's display changes.
2026-10-06 16:03:54 +01:00
rcourtman 27051deb27 Dedup node alerts by agent link and keep node thresholds on save (#2577)
A review of #2569 (node temperature alerts with a host agent) found
three more ways node alerts went wrong. Each was reproduced with a test
that fails on `main`.

## Changes

- **Dedup follows the agent's node link, not its hostname.** Node
deduplication matched an agent's hostname against node names across
every PVE instance. With two instances that each have a node called
`pve` and an agent on only one, both nodes released their CPU, memory
and disk alerts, so the second machine went unmonitored. An agent
reporting an FQDN never matched its node, so that machine alerted twice.
An operator unlinking an agent didn't hand the alerts back either. The
manager now tracks each reporting agent's `LinkedNodeID`, which the
monitor already sets automatically or from an operator link. On
pulse-prod the delly2 agent is linked to `homelab-delly2`, the same ID
as the node alert it should dedup.
- **A config save keeps node thresholds.** Saving the alert config
resolved node temperature alerts that were still over their trigger.
Reevaluation only treated an alert as a node alert when its `Instance`
equalled its node name. Current node alerts keep the PVE instance name
there (`homelab`), so they fell through to guest thresholds, and guests
have no temperature threshold. Metadata `resourceType: node` now
decides, and the `Instance` heuristic only covers legacy alerts that
have no resource type.
- **Missing readings interrupt timing runs.** Since #2569, a missing
temperature reading no longer resolves an open alert, but a pending run
still spanned the gap. One hot sample after ten minutes without readings
fired at once, and a recovery run could complete the same way. The
reducer gains `InterruptMetricRun`, which drops a pending run or
restarts a recovery run without resolving anything. The node temperature
check calls it, along with the explicit-intent pending clear, when the
reading is missing.

## Not in this PR

Two other review findings are filed separately:
- When collection fails, the poller re-presents a stale temperature as
current with no time limit, and alerting treats it as live. The fix
touches the poller, mock timestamps and what the UI shows.
- At handover, the released node alert closes with "now healthy"
wording. That needs a typed resolution reason in notifications.

## Tests

- `TestHostAgentDeduplicationFollowsNodeLink`: same-named nodes in two
instances, an FQDN hostname, an unlinked agent, and an operator unlink.
- `TestConfigSaveKeepsNodeTemperatureAlertOverTrigger`: fails on `main`.
- `TestCheckNodeMissingTemperatureInterruptsTimingRuns`: fails on `main`
(one sample back after the gap fires at once).
- The existing dedup tests now use link semantics.

`go test ./internal/alerts/...` passes, and so do the monitoring alert,
temperature, host-agent and node tests.
2026-10-06 15:55:09 +01:00
rcourtman 4775fcbf38 Base node usage ownership on agent config, all links, summary disk
A review of the previous commit found three ways the node and its
linked agent could still disagree about who alerts.

Memory ownership required a known reading on every report. When an
agent's memory reading went missing while its alert was open, the agent
kept that alert (a missing reading is not a recovery) but stopped
owning memory, so the node raised a second alert. It resolved again as
soon as the reading returned, with history and notifications for each
flip. Ownership now follows the agent's configured thresholds, not one
report's data.

With several agents linked to one node, only the lowest agent ID's
coverage counted. If that agent had CPU off and another linked agent
evaluated CPU, the node alerted on CPU as well. A metric is now owned
when any linked agent evaluates it.

Disk ownership needed any evaluated filesystem, but a linked node's
disk metric is the agent's summary filesystem (root when reported, see
models.SummaryDisk). With root disabled by a per-disk override and
/data still alerting, the node gave up its root disk alert and a full
root filesystem raised nothing. The agent now owns the node's disk
metric only while it evaluates that same filesystem.
2026-10-06 15:45:25 +01:00
rcourtman d4e94a7e34 Keep the availability result readable and give names room
A check observed from several locations showed "failed…" in its Result
cell, with a "2 locations · 2/2 reporting" chip spilling past the
column. The chip was given flex next to the badge's own inline-flex,
which left it inline beside the result, so the result clipped at every
width up to 1440px.

The result and the chip now share one line. The result keeps its
full width at the cell's right edge, and the chip, now "2/2 locations
reporting" as Settings words it, truncates to its left with the full
text on hover. Rows stay one line because the table windows them at a
single measured height.

On phones, Method and Target took the room and cut check names to
"Workshop do…". They now hide there, as the row drawer still shows
them, and the Method cell truncates with an ellipsis instead of
clipping.
2026-10-06 15:36:09 +01:00
rcourtman a6535ec0d1 Merge remote-tracking branch 'origin/main' into fix/alert-handover-resolution 2026-10-06 15:21:24 +01:00
rcourtman e3a42d86c3 Evaluate host agents in mock mode like live agents
checkMockAlerts evaluated mock nodes, guests, storage, PBS, PMG and
Docker hosts but never host agents. Live agents are evaluated by
CheckHost as each report lands; mock mode discards reports and the host
health sweep never sees fixture agents, so no agent CPU, memory, disk,
temperature or offline alert could open against mock data. A mock agent
at 94% memory sat under an 85% trigger with no alert, and alert work on
agents needed a private build to reproduce anything.

Mock passes now evaluate every fixture agent with the live lifecycle
boundaries:
- offline fixtures go through HandleHostOfflineWithCorrelation, since
  CheckHost treats its input as a fresh report and marks it online;
- agents run before nodes, so a node with an online linked agent hands
  its metric alerts to the agent on the first tick;
- an agent that leaves the estate on a runtime mock config change goes
  through HandleHostRemoved, like a deleted live agent;
- leaving mock mode routes the fixture agents through HandleHostRemoved,
  releasing hostname registrations that ClearActiveAlerts leaves behind
  and that would otherwise suppress a real node named pve1; a pass already
  in flight finds mock mode off and evaluates nothing.

Evaluating agents exposed a mock-only reading: every Kubernetes pod took
a single-pod share (10-80%) of its node's allocatable memory, so any
node running four or more pods, and its linked agent, read a constant
100%. On the public demo's estate that became two permanent critical
memory alerts no real cluster would raise. Pods now share 0.7 single-pod
footprints per node (at most 58% of allocatable in steady state), and a
60% per-node ceiling clips the transient while rescheduled pods'
readings settle, so those nodes stay under 82%.
2026-10-06 15:19:02 +01:00
rcourtman 56e88d5561 Say what an agent update brings and what the CPU count is
The outdated-agent notice told Machines users to update "for the latest
agent command support and agent-managed platform detail". Proxmox,
TrueNAS and vSphere used similar internal phrasing ("agent-contributed
node detail", "in-guest telemetry"). It now reads "for the latest fixes
and machine details", or node, system or VM details. It does not
promise Patrol actions or command support: the notice selects hosts by
version alone, and command execution is a separate setting that the
update keeps as it is.

A machine's CPU cell reads "22% (18)", and nothing explained the 18.
The shared CPU bar's hover panel now names it "CPUs". That is logical
CPUs on a host and configured vCPUs on a guest, so "Cores" would be
wrong on hyperthreaded machines.
2026-10-06 15:18:35 +01:00
rcourtman 019963b60e Give agent disk, array and sensor alerts their own resource type
Host-agent alerts about something other than the machine itself
(filesystems, disk temperatures, SMART disks, RAID and Unraid arrays,
custom sensors) cloned the host's alert metadata and carried resourceType
"agent". The alert card's Monitoring menu writes policy to their child
resource id, yet it named the Pulse agent as the inventory owner, the copy
meant for retiring the agent's own machine. CheckHost now stamps
platformType "agent" on every host alert and gives child alerts
agent-disk, agent-storage or agent-sensor, so the card still links to
Machines and uses the neutral source-system copy. Webhook payloads carry
the same resourceType values.

Each child type keeps "agent" among its CanonicalResourceTypeKeys, and
configuration re-evaluation now treats agent policy as final for
agent-keyed alerts. Before, a SMART, RAID or sensor alert fell through to
the guest switch, so disabling guest alerts resolved it on save and the
next agent report raised and notified it again.

A metric alert held between its clear and trigger thresholds returned
before merging metadata, so one restored from a checkpoint written before
platformType or these child types existed kept linking to the wrong page
until it re-fired or cleared. The hold now refreshes resourceType and
platformType only; value, message and last-seen time stay at the last
breach.
2026-10-06 15:15:46 +01:00
rcourtman 0dd4d1690d Stop presenting a silent agent's node temperature as live
A host agent linked to a Proxmox node merges with it into one read-state
row, and every PVE poll refreshes that row's LastSeen. The agent's last
sensors stay in state after it stops reporting. The node temperature
lookup checked neither: it returned the retained sensors stamped with
the row's LastSeen, so each poll presented the silent agent's last
reading as a fresh measurement. An open node temperature alert was
re-observed every poll (LastSeen kept moving, so stale-alert cleanup
never reached it), a stale hot reading could keep activating, node
history recorded it as new samples, the UI showed it as current, and
SSH collection was skipped as if a recent agent payload existed. Since
#2569 evaluates node temperature for agent-backed nodes, this holds
their temperature alerts open.

The lookup now reads the agent source's own sighting through a new
HostView.SourceStatus (matching the VM, container and disk views), uses
the agent's sensors only inside the reporting lease that keeps the agent
online, and stamps the reading with the agent's report time. Past the
lease the node has no agent reading, which alert evaluation treats as
missing rather than as evidence the node cooled down.

The poller's carry-over of a previous reading when collection fails is
also bounded to max(5m, 2 x PVE polling interval) and keeps its original
timestamp. That branch does not engage today, because the previous-node
projection carries the unified resource id and no temperature, so it
never matches the polled node; the bound keeps it from presenting a
frozen value as current once that projection is repaired.
2026-10-06 15:14:31 +01:00
rcourtman b19c6aaf70 Stamp alert platforms so cards stop guessing the policy owner (#2574)
Follow-up to #2560 and #2564. A review of both found alert cards that
still linked to the wrong platform page and named the wrong inventory
owner in the Monitoring policy menu.

**Metric and connection alerts carry their platform.** Metric alerts put
a display label in `metadata.resourceType` (`vSphere VM`, `Kubernetes
Pod`, `TrueNAS Pool`) and carried no platform, so every Kubernetes,
TrueNAS and vSphere metric alert fell through to Proxmox ("Proxmox
remains the inventory owner" on a vSphere VM CPU alert). The unified
metric and capacity evaluators now stamp the canonical `platformType`
beside the label, and connection-degraded alerts stamp it from the
connection type (a TrueNAS connection alert also linked to Proxmox). The
card already reads `platformType` first.

**Names no longer outrank types.** For alerts without platform metadata,
the card's fallback now routes by resource type first and lets the
`agent:` id prefix, the `docker` id substring and the "vmware" message
decide only when the alert has no type. Proxmox guest ids embed the
user's cluster and node names, so a VM on node `docker-01` went to
Docker (a regression from #2560), one in a cluster named `agent` went to
Machines, and a guest named `vmware-test` went to vSphere.

**Agent copy only for the agent's machine.** Libvirt VMs and Unraid
arrays in resource drawers got "Retiring this machine … Agent removal
remains available from Machines"; they now get the neutral source-system
copy. The menu also capitalises the owner label at sentence start.

Not in this change: agent disk-usage, SMART disk and Unraid array
*alerts* still carry their host's `agent` metadata (producer fix filed
separately), and alerts restored from before this change gain
`platformType` at their next full evaluation.

Verified live on a private mock stack at 1440 and 390, against main's
frontend and alert metadata in the same session. Receipt:
`frontend-modern/browser-verification/e80596c28c0384f3.json`.
2026-10-06 15:10:16 +01:00
rcourtman 123821b02a Carry a typed reason when an alert closes without recovering
When a Pulse agent is linked to a Proxmox node, the node releases its
CPU, memory and disk alerts to the agent, and any node alert still open
closes. The close has to reach integrations so a PagerDuty or webhook
incident opened from the node alert does not stay open forever, but it
went out as a recovery: "pve1 on pve1 is now healthy" for a node still
at 95% memory, with synthesized "recovery projection" evidence behind
it. #1682 is the same complaint from another trigger: a close announced
as "now healthy" when nothing recovered.

A resolved alert can now carry an AlertResolution saying why it closed.
The first reason is moved_to_agent, with the agent's alert resource ID
and name. releaseCanonicalMetricAlert, the disabled-threshold release
pulled out of evaluateCanonicalMetricAlert, takes the resolution, and
clearAlertWithResolution stamps a copy on the alert before it resolves.
It rides the Alert JSON into the resolved callback, the delivery queue,
the history row and the event log. The synthesized closing evidence and
the resolve transition say what happened instead of claiming a recovery
projection.

Notifications still send the close and change only the words: "Memory
alert moved to pve1 (Host Agent). This is not a recovery: check the
agent for the current reading." for webhook messages, a MOVED title
without the green check on ntfy, and "Pulse alert moved" on email and
Apprise. A nil resolution, which every caller passes today, leaves
ordinary recoveries and the disabled-threshold path unchanged. The node
release adopts the reason once the host-agent node link records which
metrics the agent evaluates.
2026-10-06 15:08:43 +01:00
rcourtman 15dc868c7e Tint Docker host and Machines rows for their drawer's alerts
The Docker host and Machines drawers list a resource's open alerts
through getAlertsForUnifiedResource, but their table rows matched
alerts differently, so a row could look healthy while its drawer said
"Needs attention".

Docker host rows called getAlertStyles with the row id and display
name. Docker alerts are keyed docker:<host source id>[/...] and carry
the hostname as their node, so a host whose display name differs from
its hostname (mock "Ops Services 01" / ops-services-01, three open
service-health alerts) was never highlighted. Machines rows had no
alert highlighting at all, so a machine with an open agent:<id> memory
alert looked normal until its drawer was opened.

getUnifiedResourceAlertStyles computes the existing row styles from the
same set the drawer lists. Both tables use it with the red/yellow tint
the other platform host rows use: unacknowledged alerts only, and not
on an expanded row. getAlertStyles keeps its id-plus-node matching for
the tables whose alerts are keyed that way.
2026-10-06 15:07:11 +01:00
rcourtman 9206486d56 Hand a node only the usage metrics its agent evaluates
CheckHost registered a host agent's node link before its disabled
returns. With agent alerts switched off, or a host override disabling
the agent, the node still released its CPU, memory and disk alerts to an
agent that evaluated nothing, so nothing alerted on that machine's
usage. The same happened per metric: an agent CPU threshold set to off
with the node CPU threshold on left the machine with no CPU alert.

The link now records which usage metrics the agent actually evaluated
on its last report: a live CPU threshold, a live memory threshold with
a known reading, and at least one filesystem with a live disk
threshold. CheckHost registers it after evaluation and removes it on
the disabled returns. CheckNode releases only the metrics the agent
owns and evaluates the rest itself, so deduplication can only remove a
duplicate, never the last alert for a machine.

The link also carries the agent's display name, which the handover
notification work builds on.
2026-10-06 15:07:08 +01:00
rcourtman 362ab2f235 Show open alerts in the Machines and Docker host drawers (#2575)
The Machines/Infrastructure drawer and the Docker host drawer built
"Needs attention" from the alerts embedded in each resource. Nothing in
the backend ever filled that list, so every resource carried `alerts:
[]` and both drawers stayed silent while an alert was open (a mock stack
had 0 of 142 resources with embedded alerts while 14 alerts were active;
a real dev Pulse showed `alerts: []` on a host with an open temperature
alert).

**What changes**

- Both drawers read the websocket `activeAlerts` map, the source the
Proxmox node, guest and Mail Gateway drawers already use, through a new
`getAlertsForUnifiedResource` in `frontend-modern/src/utils/alerts.ts`.
It matches the ids the backend writes for a resource (unified id,
canonical primary/superseded ids, Proxmox source id, metrics target)
and, for agent and Docker hosts, `agent:<id>` and `docker:<host source
id>` plus the disk, RAID, container and service alerts nested under
them. Rows show resource and metric like the other drawers, most severe
first.
- The dead embedded path is removed: frontend `ResourceAlert` and the
unused `hasAlerts` filter, the Proxmox summary `alertCount` nothing
rendered, and backend `ResourceFrontend.alerts` / `ResourceAlertInput`
(which also drops `"alerts":[]` from every resource in each broadcast).
- With incident alerts now reaching the infrastructure drawer, a health
reason counts as stated when it is a whole sentence of an alert's text,
so a storage incident alert that joins its summary and impact does not
repeat beside either reason.
- `utils/__tests__/alerts.test.ts` and
`internal/models/converters_additional_test.go` are registered as proof
files for the policies that own `utils/alerts.ts` and the resource
converters.

**Verification**

- Unit tests for the matcher across agent, Proxmox-linked agent, Docker
host, PBS/PMG and pod identity shapes copied from a live `/api/state`;
drawer tests for a host plus a nested disk alert, a Docker host with
nested service alerts, and the incident-sentence dedupe (each fails on
the previous code).
- Private mock stack on this tree at 1440 and 390 px: Apollo-114's
backend-raised memory alert appears in its Machines drawer, a machine
with no alert shows no section, Ops Services 01 lists its three service
alerts, and the vSphere `archive-tier` and `Edge Stateful` drawers show
one row each. Receipt:
`frontend-modern/browser-verification/89dcce7f1458e2ba.json`.
2026-10-06 15:05:28 +01:00
rcourtman 75e4ed44a6 Keep row, drawer and replication ages moving on the shared clock
The shared relative-time clock from the alert Started change reached
only PlatformTableRelativeTimeValue cells and the platform alert tables.
Other ages were still computed once at render, so they froze while a row
or an open drawer stayed mounted and its timestamp did not change:
Kubernetes Job and CronJob Detail ages and the drawer's controller
section, the drawer's container Created, Started and Finished rows and
its Last seen, the Machines identity subtitle and stale-agent tooltip,
Proxmox backup ages and their current, aging or stale band, and the
Proxmox replication compact ages, expanded Last sync and Next sync
countdown. The frozen cases are the ones that matter: a silent agent
read "seen just now" for hours, and a stalled replication scheduler's
"in 3m" never turned overdue.

Each owning component now reads useRelativeTimeNow and passes it as
`now`. The backup band measures from the later of the clock and the wall
clock, because the clock can trail by one tick and a backup that
finished inside it must not band as a future, unknown age.

Replication jobs were read once per page mount, so a moving Last sync
would have aged a job that keeps syncing and counted it overdue. The
Proxmox surface now reads them through createNonSuspendingQuery with a
30-second background poll. A failed read keeps the last jobs and the
tab, and a direct replication link holds until the first read resolves.
2026-10-06 14:59:02 +01:00
rcourtman 7ee8c0d1e2 Dedup node alerts by agent link and keep node thresholds on save
A review of the node temperature fix found three more ways node
alerts went wrong.

Node deduplication matched a host agent's hostname against node names
across every PVE instance. With two instances that each have a node
called pve and an agent on only one of them, both nodes released their
CPU, memory and disk alerts, so the second machine went unmonitored. An
agent reporting an FQDN never matched its node, so that machine alerted
twice, and an operator unlinking an agent did not hand the alerts back.
Dedup now keys on the agent's LinkedNodeID, which the monitor already
sets automatically or from an operator link. On pulse-prod the delly2
agent is linked to homelab-delly2, the node alert ID it should dedup.

Saving the alert config resolved node temperature alerts that were
still over their trigger. Reevaluation only treated an alert as a node
alert when its Instance equalled its node name, but current node alerts
keep the PVE instance name there, so they fell through to guest
thresholds, which have no temperature threshold. Metadata resourceType
"node" now decides, and the Instance heuristic only covers legacy alerts.

A missing temperature reading no longer resolves an open alert, but a
pending run still spanned the gap: one hot sample after ten minutes
without readings fired at once, and a recovery run could complete the
same way. The reducer gains InterruptMetricRun, which drops a pending
run or restarts a recovery run without resolving anything, and the node
temperature check calls it when the reading is missing.
2026-10-06 14:34:09 +01:00
rcourtman da3fcf69f7 Show open alerts in the Machines and Docker host drawers
The Machines/Infrastructure drawer and the Docker host drawer built their
"Needs attention" list from the alerts embedded in each resource. No
backend code ever filled that list, so every resource carried
`alerts: []` and both drawers stayed silent while an alert was open: a
mock stack had 0 of 142 resources with embedded alerts while 14 alerts
were active, and a real dev Pulse showed `alerts: []` on a host whose
temperature alert was open.

Both drawers now read the websocket activeAlerts map, the source the
Proxmox node, guest and Mail Gateway drawers already use, through one
matcher, getAlertsForUnifiedResource. It keys a resource on the ids the
backend writes for it (unified id, canonical primary and superseded ids,
Proxmox source id, metrics target) and, for agent and Docker hosts,
agent:<id> and docker:<host source id> plus the disk, RAID, container
and service alerts nested under them. Rows show the affected resource
and metric like the other drawers, most severe first.

The dead embedded path is gone: the frontend ResourceAlert type and its
unused hasAlerts filter, the Proxmox summary alertCount nobody rendered,
and ResourceFrontend.alerts / ResourceAlertInput on the backend, which
also drops "alerts":[] from every resource in each broadcast.

Now that incident alerts reach the infrastructure drawer, a health
reason counts as stated when it is a whole sentence of an alert's text,
so a storage incident alert that joins its summary and impact does not
repeat beside either reason.
2026-10-06 14:18:56 +01:00
rcourtman 911296fa9d Stamp alert platforms so cards stop guessing the policy owner
Two reviews of #2560 found alert cards that still linked to the
wrong platform page and named the wrong inventory owner in the
Monitoring policy menu, because the card guessed the platform.

Metric alerts put a display label in metadata.resourceType ("vSphere
VM", "Kubernetes Pod", "TrueNAS Pool") and carried no platform, so
every Kubernetes, TrueNAS and vSphere metric alert fell through to
Proxmox. A vSphere VM CPU alert said "Proxmox remains the inventory
owner". The unified metric and capacity evaluators now stamp the
canonical platformType beside the label, and connection-degraded
alerts stamp it from their connection type (a TrueNAS connection
alert also linked to Proxmox). The card already reads platformType
ahead of every fallback.

For alerts with no platform metadata, ids and messages carry names
the user chose: a Proxmox guest id embeds its cluster and node names
and a powered-off message names the guest. A VM on node docker-01
went to Docker and named the container runtime (a regression from
#2560's precedence change), a VM in a cluster named agent went to
Machines, and a guest named vmware-test went to vSphere. The card's
fallback now routes by resource type first and lets the id prefix,
id substring and message decide only when the alert has no type.

Libvirt VMs and Unraid arrays arrive through the agent, so their
drawers got "Retiring this machine ... Agent removal remains
available from Machines", which points at the host's agent. The
agent copy is now kept to the agent's own machine; everything else
an agent reports gets the neutral source-system copy. Agent
disk-usage, SMART disk and Unraid array alerts still carry their
host's agent metadata and are left to a separate change. Alerts
restored from before this change gain platformType at their next
full evaluation.

The menu also opened sentences with a lowercase owner label
("container runtime remains the inventory owner"); it now
capitalises the label at sentence start.
2026-10-06 14:17:41 +01:00
rcourtman ff3df67492 Read platform alert Started as an age that keeps moving
The Docker, Kubernetes, TrueNAS and vSphere alert tables showed
Started as "Oct 6, 10:34 AM". It clipped on phones and at 768px. An
open alert's Started cell answers how long it has been going on, so
it now reads as an age ("2h ago"), as alert cards do. The exact time
is the cell's hover text and stays in the drawer. Docker's column
takes the numeric-value alignment the other three use.

Making it an age exposed a wider bug: platform tables keep rows
mounted across data refreshes, so an age computed at render froze
while its timestamp did not change, reading "2h ago" three hours
later. That already applied to every PlatformTableRelativeTimeValue
cell (Kubernetes events, Docker volumes, vSphere activity and others).
A shared clock, useRelativeTimeNow, now ticks every 30 seconds while
any cell reads it and stops when none do. The relative-time cell and
the alert tables pass it to formatRelativeTime as `now`, and the
guardrails pin that.
2026-10-06 14:12:45 +01:00
rcourtman 7ad95fdb54 Merge remote-tracking branch 'origin/main' into fix/inline-detail-colspan-resync 2026-10-06 14:05:05 +01:00
rcourtman a1891e5f0c Keep open row drawers spanning the table when columns change
An open inline row drawer measured its colspan from the summary row's
visible cells only on mount and on resize. Hiding or showing a column
from View > Columns, or a column that depends on the data such as the
vCenter column, left the drawer with its old span. On the Machines
table, hiding Uptime with a drawer open shrank the header and rows to
939px of a 1375px table beside a blank phantom column, and showing
System left the drawer 79px short of the actions column.

InlineDetailTableRow now re-measures when the summary row's own cells
are added, removed or hidden, and when the requested colspan changes.
Changes inside a cell's content are ignored, so live metric updates do
not re-measure. When the span grows, Chromium keeps a fixed-layout
table's old column widths for the widened cell, so the row changes the
cell's width for one forced layout and then restores it.

The primitive's own test file becomes a registered frontend-primitives
proof.
2026-10-06 14:00:59 +01:00
rcourtman 33bcf698e0 Keep node temperature alerts when a host agent runs on the node
Installing a Pulse agent on a Proxmox node silently ended CPU
temperature alerting for it. CheckNode skipped every node metric,
temperature included, once a host agent with the node's hostname
registered, but CheckHost evaluates only CPU, memory and disk usage and
SMART disk temperatures, never CPU temperature. On pulse-prod the last
node temperature alert fired on 5 October; delly2 has since read 90°C
and minipc 78-87°C with no alert.

Temperature now stays with the node whether or not an agent is
registered. The node poll already merges the agent's sensors into
node.Temperature, so this gives one temperature alert per machine.

The dedup skip path also never cleared node alerts it stopped
evaluating, so a node alert open when the agent registered stayed
frozen until the agent went offline. pulse-prod shows this today: the
delly2 node memory alert, open since 29 September, beside the agent's
own memory alert. CheckNode now releases node CPU, memory and disk
alerts through the disabled-threshold path, which drops the pending run
and resolves the open alert, leaving the agent as the only owner.

A missing CPU reading also fed 0°C into the evaluator, which could
resolve an open temperature alert without any evidence that the node had
cooled. With a live trigger the check now skips the evaluation instead,
while a disabled threshold still clears.
2026-10-06 13:43:47 +01:00
rcourtman 92d946d057 Show the vCenter column only when several are connected (#2566)
With one vCenter, which most installs have, the vSphere hosts,
health-signal and activity tables repeated its name on every row. The
column told the user nothing and took room from the signal and activity
text. On a phone, the health-signal table spent 66px on a clipped "Lab
vC…".

**What changes**
- **When the column shows.** It appears only when the page's vSphere
inventory spans more than one vCenter connection, and the header and
cell drop together.
- **How connections are counted.** From the page's whole vSphere
inventory, never from the rows a search leaves. On Overview that
includes the datastores and networks its health signals cover. A
resource without a connection ID is matched to its connection by name or
host, so one vCenter is never counted twice.
- **Nothing lost.** Every row drawer still names the vCenter.
- **Hosts table.** It now reads the connection name, as the other two
tables do, instead of the raw vCenter host, which moves to the hover
text.

**Verification**
- **One vCenter (the mock), live:** the column is gone from the Overview
signals and hosts, the Health tab and Activity, and the Activity drawer
still names the vCenter.
- **Two vCenters:** I rewrote the resource API responses in the browser
with the websocket held, which moved the Edge DC inventory to an "Edge
vCenter" connection. The column returns with "Lab vCenter" and "Edge
vCenter".
- **Clip sweep against the pre-change run:** the health-signal table
clips less at every narrow width. At 390px, Signal overflows by 92px
instead of 116 and Started by 15px instead of 31. At 768px, Started
overflows by 12px instead of 29. Activity gains about 50px for its
sentences at 1440.
- **Tests:** 1,811 frontend tests pass, including new single- and
multi-vCenter tests for each table and for the connection count.
- **Review:** a second reviewer found three issues:
  - Overview counting only hosts and VMs: fixed.
- One vCenter counted twice when a resource had its name but not its ID:
fixed.
- An open drawer keeping the old column span if a vCenter is added or
removed while it is open: left alone. It needs a change to the shared
drawer-row component, which only re-measures on mount and resize, and
the trigger is rare.
2026-10-06 12:45:27 +01:00
rcourtman f373beae27 Describe releases as the fixed release train everywhere (#2565)
## Why

Releases have run on a fixed train since 4 October 2026, but most
release documentation still described the model it replaced. Readers and
agents got different answers depending on which page they read.

## What changes

- **Shipped docs** (`docs/` and the `frontend-modern/public/docs/`
mirror):
- `RELEASE_PROCESS.md` describes the train: a cut from `main` 14 days
after the last stable (only when `main` has changed), or earlier on
request; a 24-hour Preview soak; promotion of the unchanged candidate.
  - Consecutive minor versions, with no scheduled patch releases.
- A `release-blocker` issue is closed once its fix is on `main`, and
closing the last one cuts a fresh candidate.
- `AI_TRANSPARENCY.md` states the same rules and says no model chooses
release scope or timing.
- **Promotion policy:** a new **Release Train** section is the single
statement of the rules (cut, versions, soak, promote, stop, failures,
workflow checks), checked against the train's implementation. It covers:
  - when an early-cut request takes effect;
  - `rc.N` numbering;
  - the 72-hour held alert;
  - the Pro source-pair failure;
  - the 12-hour retry.

The earlier release models move under History. The stable patch path
stays because the workflow still supports it, but nothing schedules it.
- **Checklist, source of truth, development protocol, control plane,
workflows README, RC-to-GA template and the deployment-installability
contract:** now consistent with that section.
- No 72-hour soak, no steward admission, no severity-scheduled patches.
- v6 resolves to `main`, and only 6.3.2 and 6.4.3 to 6.4.6 stay bound to
release lines.
  - Helm charts are packaged from the validated release tag.
  - The stable patch dry run is optional, as the policy already said.
- **Workflow comments:** `release-signing-preflight.yml` no longer
claims a nightly dispatch that was switched off, and `helm-pages.yml` no
longer names the retired steward.
- **`status.json`:** the release-train coverage gap becomes a resolved
decision.
- **Scripts:** removed
`scripts/release_control/internal/resolve_release_promotion.py` and its
test. It was an unused duplicate of the live resolver and still enforced
a 72-hour soak. The live resolver's comment and error text now name the
train.

## Checks

- Docs mirror check passes.
- `release_promotion_policy_test` and `resolve_release_promotion_test`
pass (108 tests).
- Registry, contract and status audits pass, as does
`subsystem_contracts_test`.
- The canonical completion guard passes in its CI form.
2026-10-06 12:34:16 +01:00
rcourtman 54a9a6451b Link alert cards to their platform from incident metadata (#2564)
The alert card chose its platform page by resource type, then by
searching the message for "vmware". Provider incidents sit on canonical
`vm`, `storage`, `network` and `agent` types, so that only worked while
a summary happened to name the platform. #2559 added an
`incidentProvider === 'vmware'` check for vSphere alone. On main, other
platforms still fall through:

- **TrueNAS pool incidents** link to the Proxmox overview, and Alerts ›
Overview › More › Monitoring says "Proxmox remains the inventory owner".
- **Agent alerts** link to `/machines`. No such route exists, so the
link opens "Page not found: No route matched /machines".
- **TrueNAS metric alerts** (`truenas-pool`, `truenas-dataset`, …) fall
through to Proxmox the same way.

**What changes**
- The card reads the platform from an explicit `platformType`, then the
incident's `incidentProvider`, then the resource's `resourceSources`,
through the shared `resolvePlatformTypeFromSources`. A PBS incident that
Pulse raises itself (provider `pulse`) resolves by its `pbs` source.
- That platform maps to its page through
`PRIMARY_PLATFORM_NAV_SCOPE_IDS` and the `routing/resourceLinks.ts` path
builders, and the same canonical id goes to the Monitoring policy menu,
so the link and the named owner agree. Raw route strings in the card are
gone.
- The resource-type and message fallbacks remain only for alerts whose
metadata names no platform, now with `truenas-` types, and with agent
alerts sent to `/standalone/machines`.
- vSphere links no longer depend on the alarm wording at all. #2559
already made them right on main by the provider check; this replaces
that special case with the general resolver.

**Verification**
- `AlertOverviewAlertCard.system-scope.test.tsx`: a vSphere VM alarm
whose message lacks "VMware", a TrueNAS pool incident, a PBS incident
with provider `pulse`, and agent and TrueNAS metric alerts without
incident metadata. Each asserts the link and the platform passed to the
Monitoring menu. All five fail against main's card; against the card
from before #2559, the vSphere VM alarm linked to `/proxmox/overview`.
- Private mock stack at main `af4e6a493c`, 1440 and 390: a vCenter alarm
on the mock VM client-portal-01 (message "Virtual machine memory usage")
and an agent-host CPU alert, appended to the live websocket
`activeAlerts` with Playwright `routeWebSocket`, plus the mock's own
TrueNAS pool incident. Each link was followed to the vSphere overview,
TrueNAS overview and Machines, and each menu named VMware vSphere,
TrueNAS and Pulse agent. With main's card swapped back in under Vite,
the TrueNAS card went to Proxmox with Proxmox as owner and the agent
card hit Page not found.
- Receipt: `frontend-modern/browser-verification/977a32825e65be8f.json`.
2026-10-06 12:15:15 +01:00