Merge branch 'refactor/drop-unread-patrol-state-members' into fix/drop-unread-circuit-breaker-read

This commit is contained in:
rcourtman
2026-10-08 00:36:23 +01:00
226 changed files with 14371 additions and 2947 deletions
+10 -5
View File
@@ -836,9 +836,12 @@ delivery for all resources.
- `GET /api/admin/reports/catalog` (admin, `settings:read`)
- Returns the canonical reporting catalog for the settings surface, including locked-state teaser copy, enabled-surface guidance copy, performance-report options, canonical single-report filename subject, canonical fallback filename date style, and the nested VM inventory export definition.
- Metadata route: readable without the `advanced_reporting` feature so locked admin surfaces can render the same reporting definition before upsell.
- `GET /api/admin/reports/generate` (admin, `settings:read`)
- Query params: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
- `POST /api/admin/reports/generate` (admin, `settings:read`)
- Body fields: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
- If `title` is omitted, the backend applies the canonical default title for that resource report.
- When Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method.
- `GET /api/admin/reports/generate` (admin, `settings:read`)
- Takes the same fields as query params and returns the same report, except that it never calls an AI provider: a PDF carries the deterministic summary and a note saying AI narration needs `POST`. Other methods return `405` with `Allow: GET, POST`.
- `POST /api/admin/reports/generate-multi` (admin, `settings:read`)
- Body fields: `resources` (1-50 entries of `{resourceType,resourceId}`), `format`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
- If `title` is omitted, the backend applies the canonical default fleet report title.
@@ -851,8 +854,8 @@ Validation and limits:
- Malformed `start`/`end` values, `end` not strictly after `start`, or report windows over 366 days return `400 invalid_time_range`.
- `metricType` must match `[a-zA-Z0-9._:-]+` and be <= 64 chars, otherwise `400 invalid_metric_type`.
- `title` must be <= 256 chars, otherwise `400 invalid_title`.
- Multi-report body max size is 1MB; oversized payloads return `400 body_too_large`.
- Multi-report bodies reject trailing payload and unknown JSON fields with `400 invalid_body`.
- Report body max size is 1MB; oversized payloads return `400 body_too_large`.
- Report bodies reject trailing payload and unknown JSON fields with `400 invalid_body`.
- VM inventory export only accepts `csv`.
Common reporting error codes:
@@ -891,7 +894,9 @@ For an existing incident whose notifications should be paused without clearing
it, use the snooze and unsnooze endpoints below.
- `GET /api/alerts/config`
- `PUT /api/alerts/config`
- `PUT /api/alerts/config` — replaces only the top-level keys the body
carries; a key left out keeps its stored value, and the merged config is
normalized as before. The body must be a JSON object.
- `GET /api/alerts/deadman/config` — returns only whether an external watchdog
is configured; `pingUrl` is `***REDACTED***` when present and never returns
the credential-bearing URL
+4 -2
View File
@@ -260,10 +260,12 @@ Each client runtime (or organization) generates its own reports, scoped to
that client's resources:
- **UI**: Settings → Data & Reports.
- **API**: `GET /api/admin/reports/generate` (single resource) and
- **API**: `POST /api/admin/reports/generate` (single resource) and
`POST /api/admin/reports/generate-multi` (up to 50 resources per report),
returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID`
or an org-bound token.
or an org-bound token. `GET /api/admin/reports/generate` with query
parameters still returns a single-resource report, but never adds the
Pulse Assistant narrative.
- **Schedules**: `GET`/`POST /api/admin/reports/schedules`,
`PUT`/`DELETE /api/admin/reports/schedules/{id}`, and
`POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit
@@ -248,6 +248,18 @@ Without comparable identity (a SAS, SCSI or USB replacement with no WWN on one
side) the row still matches. Report admission and the SMART wire contract are
unchanged; the regression is
`TestHostAgentSMARTRowForSwappedOutDiskDoesNotLendItsIdentity`.
A PVE disk that a still-reporting linked Agent's SMART row matches is marked
`AgentSMARTReported` (internal poll evidence on `models.PhysicalDisk`, never
serialized); disks the poller builds from that report when the Proxmox disk
query fails go through the same merge, and an Agent whose lease lapsed owns
none. The Agent then owns the disk's temperature alert
through `CheckHost`, and the PVE disk temperature check closes its own as
moved to the Agent, so one disk never alerts twice. Report admission, the SMART
wire contract and Agent authority are unchanged;
`TestMergeHostAgentSMARTIntoDisksMarksDisksTheAgentReports` in
`internal/monitoring/physical_disk_roundtrip_test.go` and
`TestPhysicalDiskAgentSMARTReportedStaysInternal` in
`internal/models/deepcopy_test.go` pin the marker.
Assistant historical metric wiring uses the current monitor's retained store
and registry metrics coordinates. Historical reads do not alter enrollment,
@@ -765,6 +777,15 @@ not fenced: a live report already past its mock-mode check when mock mode is
switched on can still be evaluated after the clear, and its alerts can persist
until mock mode is left.
Every tenant monitor registers its own fixture agents, and their node links,
in its own alert manager, so the toggle forgets the fixture agents of every
running monitor, not only those of the monitor it was called through. A link
left behind would keep owning its fixture node's usage alerts after mock mode
ends. `TestLeavingMockModeReleasesFixtureAgentNodeLinksOnEveryRunningMonitor`
in `internal/monitoring/monitor_host_agent_removal_lifecycle_test.go` switches
through the default monitor and fails if the other tenant's node stays
suppressed.
Physical-disk evidence collected by a host agent must survive projection back
into monitoring's models. Absent evidence has to carry its declared sentinel
rather than a zero value that reads as a real measurement: an absent
@@ -3669,6 +3690,14 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Alert settings saves do not touch agent lifecycle
`PUT /api/alerts/config` in `internal/api/alerting/alerts.go` now keeps stored
values for the top-level keys a client leaves out. Agent alert defaults
(`agentDefaults`) follow the same rule as every other key; enrollment,
reporting leases, commands and agent removal are unchanged.
`internal/api/alerting/alerts_test.go` pins the handler.
### VM guest execution admission (backup precaution)
`AgentRegisterPayload.guest_exec_guard_version` is optional; version 1 on a
@@ -4703,6 +4732,12 @@ agent-lifecycle operations. If those routes are called directly, the API must
fail them as unsupported rather than presenting file diffs or reverts as
agent command rollback, enrollment repair, update rollback, or fleet-control
authority.
Assistant session mutations (`/abort`, `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer` under `/api/ai/sessions/{id}`) accept only `POST`; any other
method that reaches those handlers gets `405` before touching session state.
That method guard
is AI-runtime transport hardening in `internal/api/ai_handler.go`; it adds no
agent command, enrollment, update, or fleet-control path.
That same shared dependency now also assumes hosted cloud handoff authorizes
tenant org access before browser lifecycle continues. Lifecycle-adjacent opens
into hosted workspaces may depend on `internal/api/cloud_handoff_handlers.go`,
@@ -7630,6 +7665,16 @@ bounded and classified temperature nodes, and leaves the host report intact
when the helper is absent or incompatible. It does not treat Windows ACPI
thermal zones as hardware sensor evidence, accept remote provider locations,
or grant Pulse hardware-control authority.
### Single-resource report AI narration requires POST
`internal/api/metrics_reporting_handlers.go`, `internal/api/router.go` and
`internal/api/ai_handlers.go` changed only how `/api/admin/reports/generate`
reads its fields (a `POST` JSON body or `GET` query parameters), which of those
may use the tenant's AI narrator (`POST` only), and how a `GET` finds Patrol
findings (`ExistingAIService`, which never constructs a tenant AI service). No
agent registration, enrolment, install, update, removal or report ingest path
changed.
### Agent profile adoption is reported as a count only
The usage telemetry snapshot reads `LoadAgentProfiles` and reports
@@ -8783,6 +8828,40 @@ Focused proof lives in `internal/monitoring/physical_disk_roundtrip_test.go`
(`TestNestedProxmoxDefaultQEMUSerialsStayPerNode` and
`TestTrueNASPlaceholderDiskSerialsStayPerApplianceAndShareOneHistoryKey`).
### Unraid serials key agent disk history
`internal/monitoring/monitor_agents.go` changed only so the SMART and disk I/O
history writers key a host agent's disk by the serial its resource carries: the
SMART row's own, else the one the host's Unraid inventory reports for that disk
(`HostSMARTDiskMetricID`, `HostUnraidDeviceMetricID`). While the Unraid row
names such a disk, its SMART and I/O history is written under that serial, which
its chart already reads, instead of its WWN or host/device key. Agent
registration, enrolment, install, update, removal, report identity and disk
source IDs are unchanged. Focused proof lives
in `internal/monitoring/monitor_host_agents_test.go`
(`TestHostDiskIOMetricResourceIDFallbacks`) and
`internal/monitoring/issue1595_collection_trust_test.go`
(`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`).
### Unraid temperatures chart agent disk history
`internal/monitoring/monitor_agents.go` changed only so the SMART history
writer charts the temperature a host agent's disk shows. A SMART row without
its own reading shows the one the host's Unraid inventory reports for the disk,
when that inventory row describes the disk (`HostSMARTDiskTemperature`), and a
disk only the Unraid inventory reports,
such as a member the agent's SMART collection skips under `--disk-exclude`,
shows that row's reading (`HostUnraidDiskTemperature`). Both are now written
as `smart_temp` under the key the disk's metrics target reads
(`HostSMARTDiskMetricID`, `HostUnraidDiskMetricID`); a reading the disk does
not show as collected, a spun-down disk's or an expired host's, is not. Agent
registration, enrolment, install, update, removal, report identity and disk
source IDs are unchanged. Focused proof lives in
`internal/monitoring/monitor_host_agents_test.go`
(`TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART`) and
`internal/monitoring/issue1595_collection_trust_test.go`
(`TestAgentDiskChartsTheUnraidTemperatureItShows`).
### Windows braced MachineGuid does not abort agent startup
The Windows unified agent resolves host information through gopsutil's combined
@@ -8902,3 +8981,20 @@ agent from its last report instead of from the PVE poll, and mark it stale at
their own heartbeat cutoff (`fleethealth.AgentStaleThreshold`, which is
separate from the monitoring reporting lease). Agent registration, enrolment,
install, update, removal and report identity are unchanged.
### Demo write guard ignores websocket upgrade headers
`internal/api/demo_middleware.go` changed only so a `POST`, `PUT`, `PATCH` or
`DELETE` carrying `Upgrade: websocket` is judged by the demo read-only guard
like any other write instead of always reaching its handler. Agent command
websocket handshakes to `/api/agent/ws` are HTTP/1.1 `GET` requests, the only
transport its gorilla upgrader serves, and still connect on a demo instance.
No agent registration, enrolment, install, update or removal path changed.
### Demo mode hides the pprof routes
`internal/api/demo_mode_operations.go` changed only so the demo guard on a
`DEMO_MODE` instance answers `/debug/pprof` and every path below it with `404`
for every method. No agent route lives under `/debug/`, so agent registration,
enrolment, install, update, removal, report ingest and `/api/agent/ws`
handshakes are unchanged on a demo instance.
@@ -128,6 +128,25 @@ on component mount. Closing clears the registered input so later keyboard
commands cannot target a detached composer. A handoff must leave Escape and
keyboard input in Assistant, not the underlying alert search.
### Process-wide memory ID sequences
`internal/ai/memory` mints incident, incident-event, remediation-record and
context-memory IDs from package-level sequences that every store in the process
shares. Multi-tenant monitoring gives each tenant monitor its own
`IncidentStore`, and their alert checks call `RecordAlertFired` from separate
goroutines, while a store's mutex covers only its own state. Each sequence is
therefore an `atomic.Int64` minted through `nextSequencedID` in
`internal/ai/memory/ids.go`, never a plain package counter. The ID shape stays
`<prefix><local second>-<n>`, but `n` no longer wraps at 1000. The old
`counter%1000` suffix repeated an ID whenever one generator minted more than a
thousand in a wall-clock second, and incident notes resolve an incident by ID
while timeline merges deduplicate events by ID. Within one process IDs are now
unique by construction. Across restarts only the timestamp separates them, so a
repeated local second (DST fall-back or a backward clock step) can still meet a
restarted sequence's suffix. Persisted IDs and the stored format are unchanged.
`internal/ai/memory/ids_test.go` mints from every generator concurrently and
records alerts into two tenant stores concurrently; run it under `-race`.
### Canonical incident-history queries
Incident context reads `IncidentStore.QueryIncidents`, whose page contains
@@ -3556,6 +3575,12 @@ query...`, and `Reading storage...` before streamed tool arguments are
answer (nothing streaming, no error block, no pending approval or
question, and a user prompt available to re-send) because the undo
boundary only operates on the last durable turn.
Session mutations stay POST-only at the handler: abort, summarize, fork,
undo, redo, and steer refuse every other method with `405` and
`Allow: POST` before resolving the Assistant service. A `GET` or `HEAD`
that the demo-mode read-only guard or the CSRF check admits as a read
therefore cannot cancel a running turn, compact or rewind a transcript,
or persist a fork.
The empty Assistant drawer may surface recent non-empty sessions as direct
resume actions using the backend session list already owned by the drawer;
it must not create a parallel recent-chat store or product-authored prompt
@@ -7515,6 +7540,18 @@ prompt budget. Reporting is therefore an additive consumer of AI
runtime, not a new ownership boundary, and the narrator/findings
surfaces inherit the same governance the rest of the canonical AI
runtime already enforces.
Report narration is caller-requested spend, so on the single-resource HTTP
route only `POST /api/admin/reports/generate` reaches it (fleet reports, from
`POST .../generate-multi` or a report schedule, use the fleet narrator). The
reporting narrator resolver goes through `AISettingsHandler.GetAIService`,
which constructs a tenant service on first use and can then list provider
models or start background discovery. A GET single-resource report never calls
that resolver:
it narrates deterministically and reads Patrol findings through
`AISettingsHandler.ExistingAIService`, which returns only a service that is
already running and never constructs one
(`TestAISettingsHandler_ExistingAIServiceNeverConstructs`, and through the
router's real wiring `TestContract_SingleReportGETDoesNotConstructTenantAIService`).
The same canonical AI runtime now also owns the fleet-level report
narrative through `report_fleet_narrator.go`. `Service` implements
@@ -525,7 +525,7 @@ active-state and intent-policy linkage while persisting through the stable
A per-metric threshold is off whenever its trigger is `<= 0`. That boundary is
engine truth (`internal/alerts/canonical_metric.go`,
`internal/alerts/config_runtime.go`), not a display convention: `-1` is the
value Pulse writes, and `0` disables the metric just as completely because
value the editors stage, and `0` disables the metric just as completely because
earlier builds advertised `0` as the disable value and those overrides are still
on disk. Every threshold editor reads that same `<= 0` rule through
`frontend-modern/src/components/Alerts/alertResourceTableModel.ts` rather than
@@ -543,7 +543,28 @@ the metric's enabled default instead, and only clears the override when the
inherited default is already enabled. Editors must also not manufacture an off
state out of an empty input: a cleared box is mid-edit, and coercing it to `0`
disabled the metric in the engine while the row still showed On. Disabling is
the toggle's job, and the toggle writes the canonical `-1`.
the toggle's job, and the toggle stages the canonical `-1`.
The save path writes the backend's default contract, not the editors' sentinel.
Alert config normalization reads a negative trigger on a global default as
unset and restores the factory threshold, so `buildAlertsConfigurationPayload`
in `frontend-modern/src/features/alerts/alertsConfigurationModel.ts` writes
every Off global default as trigger `0`. Sending the staged `-1` through made
every Off default with a positive factory threshold come back On at that
threshold after save and reload; only guest and node CPU, memory and disk
escaped, because normalization leaves them alone. Overrides keep `-1`, which
the override path stores as written. Because `-1` and `0` are the same
setting, the Global Defaults "Custom" badge compares Off values as equal
rather than by raw number, so the factory `-1` I/O defaults do not read
Custom against a saved `0`. An unset factory value stays distinct from Off,
since unset can mean the default follows another setting. A row's
custom-threshold marker keeps comparing raw values: it drives the row's revert
control and override removal, so an explicit `-1` override must stay visible
against an Off default even though both alert the same way.
`frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts`
pins the payload for every section, and
`internal/alerts/config_defaults_off_test.go` pins that a zero default stays
off through `UpdateConfig` and raises no alert.
External availability-probe reporting loss owns one canonical
`external-probe-unavailable` alert per assigned agent, not one alert per target.
@@ -1093,6 +1114,43 @@ history and affirmative recovery. This is source-fixture lifecycle proof, not
an installed registry/destination result or a diagnosis of a reporter's daily
resolve/reopen cycle.
### A settings save keeps the alert settings it did not send
`PUT /api/alerts/config` applies the body to the stored configuration through
`Manager.ApplyConfigUpdate` and `ApplyAlertConfigUpdate` in
`internal/alerts/config/update.go`: a top-level key the client sent replaces
that setting whole, a key it left out keeps the stored value, and the result
is normalized as any update is. The thresholds page has no control for
flapping detection (`flappingEnabled` and its window, threshold and cooldown),
alert TTL cleanup (`maxAlertAgeDays`, `maxAcknowledgedAgeDays`,
`autoAcknowledgeAfterHours`) or `customRules`, so it never sends them. The
handler used to decode the body into a zero `AlertConfig`, so every thresholds
save turned flapping detection and TTL cleanup off, reset the flapping tuning
to its defaults, dropped custom rules, and persisted that. The page's
`flapping`, `aggregation` and `ioNormalization` payload objects are not
`AlertConfig` fields and never enabled anything. A stored config keeps what it
holds, including values an earlier save zeroed; nothing rewrites them.
The body is decoded on its own, so sent keys mean exactly what the decoder
makes of them, duplicates and case-folded keys included. Unsent fields are
copied from a JSON round trip of the stored config, so the result shares no
maps or slices with it. `ApplyConfigUpdate` reads, merges and applies under
the manager lock, so two concurrent partial saves cannot revert each other in
memory, and returns a JSON-cloned snapshot so that persistence, which
normalizes the config it is handed in place, never writes into the live
config's maps.
`internal/alerts/config/update_test.go` covers unsent, explicit-off,
case-folded, duplicate-key, malformed and full-body updates, a sent object
replacing its stored value whole (a sent `truenasDiskDefaults: {}` does not
inherit the stored temperature) and the no-sharing rule.
`TestUpdateConfigKeepsSettingsAThresholdsSaveDidNotSend` and
`TestApplyConfigUpdateKeepsConcurrentPartialUpdates` in
`config_validation_test.go` run saves through the manager, and
`TestUpdateAlertConfig_KeepsStoredValuesForUnsentKeys` in
`internal/api/alerting/alerts_test.go` drives the handler against a real
manager.
### Confirmed empty storage is recovery evidence
Static storage capacity evaluation must admit a zero usage observation when
@@ -1452,7 +1510,8 @@ That same guest-threshold owner also governs guest-derived lifecycle and
posture alerts. Snapshot age, backup age, powered-off state, and
configuration-change reevaluation must all construct a canonical lightweight
guest snapshot and route threshold resolution through the shared
guest-defaults → filter-driven custom rules → guest-override chain.
guest-defaults → filter-driven custom rules → guest-override → `pulse-relaxed`
chain.
That canonical guest context must preserve the live guest name and tags for
snapshot and backup posture evaluation. Ignored prefixes, `pulse-no-alerts`,
configured ignored tags, and required-tag filtering must resolve through the
@@ -1462,6 +1521,27 @@ operator's suppression policy.
Passing `nil` guest context or resolving only overrides/defaults is forbidden
because it silently bypasses custom guest rules and makes guest lifecycle
alerting diverge from running-guest metric truth.
The `pulse-relaxed` step lives in `effectiveAlertPolicyNoLock`, not in
`CheckGuest`, so polling and config-save reevaluation judge a relaxed guest's
alert by one threshold. Reevaluation rebuilds the guest from alert metadata,
so `guestSnapshotFromAlert` reads back the `tags` the evaluator recorded
(`[]string` live, `[]interface{}` after a restart). Without them a save judged
the alert by unrelaxed thresholds, resolved one raised under an unset
threshold's relaxed floor with a recovery notification, and the next poll
raised it again. Those tags must stay current: `metricOptionsWithTags` drops
the key from an open alert once its resource has no tags, or a removed
`pulse-relaxed` would keep judging the alert by the relaxed trigger. The tag
lifts CPU, memory and disk triggers to a 95/92/95 floor and gives an unset
threshold that floor, but an Off threshold (trigger <= 0) stays Off: relaxing
a guest must never raise an alert its config turned off. A filesystem's own
override still replaces the relaxed guest disk threshold for that filesystem,
on save as while polling. Proof:
`TestConfigSaveJudgesRelaxedGuestAlertByRelaxedThresholds`,
`TestConfigSaveKeepsRelaxedGuestFilesystemOverride`,
`TestPulseRelaxedKeepsOffGuestThresholdOff` and the Off case of
`TestApplyRelaxedGuestThresholds` in `internal/alerts/alerts_test.go`, and
`TestCheckGuestDropsRemovedTagsFromOpenAlerts` in
`internal/alerts/unified_eval_test.go`.
That same guest-alert owner also has to retire per-disk guest alerts when the
guest stops, disk alerting is disabled, or the reported disk set changes.
Canonical guest disk identity is only valid while the guest still exposes that
@@ -1830,14 +1910,84 @@ A disk is hot from its trigger. Alerts and Patrol findings stay open until the
reading falls to the clear value, or under the trigger when there is no band
below it. The Physical Disks verdict and the TrueNAS Health cell judge only the
current reading against the trigger. PDF reports colour disk temperatures by
the same thresholds. Judges that still differ: TrueNAS disk temperature alerts
use `TrueNASDiskDefaults.Temperature` (a flat 55/50), and the TrueNAS disk
drawer tones the reading from a fixed 55C.
the same thresholds.
`TestDiskTemperatureThresholdMatchesCheckHostPolicy` in
`internal/alerts/threshold_resolution_shared_test.go` pins per-type resolution, a raised
NVMe trigger that `CheckHost` also honours, the copy, the disabled default and
the nil manager.
TrueNAS disk temperature alerts follow the policy too:
`trueNASDiskTemperatureDefaultNoLock`
(`internal/alerts/truenas_disk_temperature.go`) is the type-default tier of
`effectiveAlertPolicyNoLock` for `truenas-disk`, so an unset
`TrueNASDiskDefaults.Temperature` resolves per disk type (the
`alertPolicyQuery.DiskType` the unified input carries from
`PhysicalDisk.DiskType`). A TrueNAS-wide value the user saved under TrueNAS
Disks replaces it for every TrueNAS disk, as a host Disk Temp override does for
an agent's disks, 0 meaning off, and a per-disk override beats both. The alert
records `diskType` so a config save re-judges it per type; one without it is
held to the lowest per-type trigger. A retained reading
(`diskinventory.TemperatureCollected` false) is no evidence and raises nothing.
The factory value is unset. `normalizeTrueNASDiskTemperature` drops a stored
55/50 only from a config without `TrueNASDiskTemperatureByType`, because the
thresholds page wrote that flat factory value back on every save; the page
writes the marker, so a 55 typed later is kept. Every config `GetConfig`
returns carries the marker, so a client that reads and writes back keeps it.
Without the marker (an old saved file, or a page loaded before the upgrade)
55/50 is read as that unchosen factory value; a client building a config from
scratch with an explicit TrueNAS-wide 55 has to send the marker. The TrueNAS
Disks Global Defaults cell reads `By type` while unset
(`globalDefaultFallbacks` on `ResourceTable`,
`resolveAlertResourceGlobalDefaultCell`), or Off when the agent Disk Temp
default switches the policy off, and switching it on then stages an explicit
TrueNAS-wide 55 because unset would stay off. Each disk row inherits its type's
trigger from the unsaved editor state (`resolveTrueNASDiskTemperatureDefault`).
The TrueNAS disk drawer tones a current reading from the same per-type trigger
(`getDiskTemperatureThresholds` passed into `buildTrueNASDetailSections`), so it
no longer judges heat from a fixed 55C. Judges that still differ: the TrueNAS
storage table and drawer do not apply a TrueNAS-wide value or per-disk
override.
`TestTrueNASDiskTemperatureAlertsFollowDiskTemperaturePolicy` and
`TestTrueNASDiskTemperatureAlertIgnoresRetainedReading` in
`internal/alerts/unified_eval_test.go` pin the tiers and retained readings;
`TestTrueNASDiskTemperatureAlertsReevaluatePerDiskType` and
`TestTrueNASDiskTemperatureAlertWithoutDiskTypeHeldAtLowestTrigger` in
`internal/alerts/alerts_test.go` pin re-judging on a config save;
`TestNormalizeTrueNASDiskTemperatureKeepsOnlyChosenValues` pins the
saved-config migration.
Proxmox physical disks alert on temperature under the same policy.
`CheckProxmoxDiskTemperature` (`internal/alerts/proxmox_disk_temperature.go`)
raises a `diskTemperature` metric alert under the disk's PVE alert reference
(`ProxmoxPhysicalDiskAlertResourceID`, resource type `proxmox-disk`), judged by
`proxmoxDiskTemperatureThresholdNoLock`: the disk type's `DiskTempByType` entry
under the agent Disk Temp default, which switches it off for every type. It
carries the disk's serial, WWN and `diskType` like the health and wearout
alerts, so `ProxmoxPhysicalDiskAlertIdentifiers` lists its
`<reference>::metric-threshold:diskTemperature` identifier for history
ownership, and `reevaluateActiveAlertsLocked` re-judges it per disk type (the
lowest per-type trigger when the type was never recorded) instead of falling
through to guest thresholds that have no disk temperature. A disk a
still-reporting linked agent lists in its SMART report is the agent's: its
alert closes with the `moved_to_agent` resolution and `CheckHost` raises the
agent's own. The handover happens on the next disk poll, so for that one
interval an agent newly linked to a disk with an open PVE alert can hold its
own alert beside it. A lapsed agent owns nothing, so the node's own readings
keep alerting while its agent is silent. An
excluded disk, or a policy switched off, closes the alert; a poll without a
current reading holds it. Like the health and wearout alerts, a disk that
leaves the inventory keeps its alert until the 24-hour stale sweep. The
`proxmox-disk` alert delay defaults to the factory 5 seconds, so a warning
waits out the same 300-second noisy-gauge stability window agent and TrueNAS
disk temperature warnings do, and a critical reading bypasses it.
`TestProxmoxDiskTemperatureAlertsFollowDiskTemperaturePolicy`,
`TestProxmoxDiskTemperatureAlertOwnershipAndHolds`,
`TestProxmoxDiskTemperatureWarningWaitsOutStabilityWindow`,
`TestProxmoxDiskTemperatureAlertsReevaluatePerDiskType` and
`TestCheckDiskHealthAlertsCarryHistoryOwnershipIdentity` in
`internal/alerts/alerts_test.go` pin the policy, ownership, holds, re-judging
and identifiers.
### Agent disk temperature alerts clear when their disk leaves the report
`CheckHost` also clears a disk temperature alert, and drops its pending
@@ -1867,6 +2017,78 @@ well as its alerts.
`internal/alerts/host_unraid_lifecycle_test.go` pins the clear, the holds, the
restarted count and the pending-run and count cleanup.
### Proxmox node cleanup keeps platform metric alerts
`CleanupAlertsForNodes` runs on every Proxmox node poll (and every mock alert
tick) and removes alerts whose `Node` is not a current Proxmox node or PBS
instance. Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or
platform host in `Node` and a display label such as `TrueNAS Disk` or
`Kubernetes Pod` in `metadata.resourceType`, which the preserve list's
platform words never matched. The cleanup used to delete them silently on
each poll while their reducer incident stayed firing, so the next evaluation
re-created each one and handed it to the alert callback again as a new
alert. `shouldPreserveAlertOutsideNodeCleanup` now keeps any alert carrying
`metadata.platformType`, or whose label maps through
`CanonicalAlertResourceType` to a Kubernetes, TrueNAS or vSphere type.
`TestCleanupAlertsForNodesKeepsPlatformMetricAlerts` in
`internal/alerts/alerts_test.go` pins both signals, and its
sibling pins that a guest alert on a removed Proxmox node is still removed.
### Agent disk temperature alerts judge the reading the disk shows
`CheckHost` judges each agent disk on the temperature, collection state and
disk type the disk shows, read by `unifiedresources.HostDiskTemperatureReadings`
from the same adapters the registry builds the disks with. A SMART row shows its
own reading or, when the agent's probe returned none, the one the host's Unraid
inventory reports for that disk (`HostSMARTDiskTemperature`), and keeps its
`agent:<host>/disk_temp:<device>` resource. An Unraid inventory row whose disk
key no SMART row carries, a disk only the inventory lists, shows that row's
reading (`HostUnraidDiskTemperature`) and its own transport as disk type, under
its kernel block device token, the resource a plain Linux SMART row for the
same device uses. A SMART row and an Unraid row with one key are one registry
disk even under different device labels, such as a controller member
(`0 [megaraid,0]`) and its Unraid device (`sda`), and alert once, under the
SMART row's resource. When smartctl and Unraid name
one device with different serials the registry shows two disks on it, and the
device's one alert judges the reading that stands highest against its own disk
type's threshold (`judgedHostDiskTemperature`): over its trigger first, then
inside its recovery band, then the larger margin over the trigger, with a tie
keeping the reading listed first.
Before this `CheckHost` read only the SMART row's own field, while the Physical
Disks Temp cell, the Running Hot verdict ("at or above its alert threshold")
and Patrol judged the Unraid reading against the same trigger, so such a disk
could read as at its alert threshold with no alert. Unraid's own warning and
critical disk temperature notifications do not make this redundant: Pulse
already alerts on the smartctl readings and array state of the same hosts,
which Unraid also notifies on, and a user who routes alerts through Pulse sets
these thresholds here.
Only a reading collected now fires or resolves
(`HostDiskTemperatureReading.Collected`: no standby row, and
`diskinventory.TemperatureCollected`), so a spun-down disk holds the alert
like a listed disk without a temperature. A disk only the
inventory lists whose device matches the agent's `--disk-exclude` patterns
(`fsfilters.MatchesDeviceExclude`) raises nothing, and an alert it raised before
the exclusion resolves on the next report (`clearHostDiskTemperatureResources`),
whatever the SMART list holds, unless another listed reading uses the same
resource: `docs/UNIFIED_AGENT.md` documents exclusion as
removing the disk from monitoring, and linked Proxmox hosts already judge an
excluded disk healthy. A disk only the inventory lists is also judged in a
report whose SMART list is empty, because the disk shows the inventory's
reading in that report. The absence cleanup above still runs only on a
non-empty SMART list, and such a report neither counts toward nor restarts a
disk's three, so a disk that leaves the inventory while every report's SMART
list stays empty keeps its alert until the 24-hour stale-alert sweep.
`TestCheckHostJudgesTheUnraidTemperatureADiskShows` and
`TestCheckHostHoldsUnraidDiskTemperatureAlertUntilAReadingOrDeparture` in
`internal/alerts/host_unraid_lifecycle_test.go` pin the fallback, the
inventory-only disk, the empty SMART list, two disks on one device (also of
different types), a controller member under another device label, standby,
exclusion, the disk type
and host override thresholds, the holds, the handover to a SMART reading,
departure, and the exclusion clear with and without SMART rows and beside a
listed SMART row. `TestJudgedHostDiskTemperatureOutranks` pins the ranking.
### Configured flapping thresholds remain reachable
Every accepted positive `FlappingThreshold`, including values above ten, must
@@ -1894,6 +2116,42 @@ controls in `internal/alerts/flapping_threshold_test.go` exercise both sweeps,
drained and retained windows, dispatch callbacks and delivery diagnosis. These
modeled-time controls are not installed notification-destination acceptance.
### Default thresholds keep a positive trigger's clear below it
PBS, node temperature, agent, Kubernetes, TrueNAS and vSphere global defaults
go through `normalizeThresholdPointer` in `internal/alerts/config/normalize.go`
from `UpdateConfig`. The shared persistence normalization in
`internal/config/persistence.go` applies the same normalizer, through
`NormalizeHysteresisThreshold`, to the agent, node temperature and storage
pairs. A missing or negative threshold takes the factory default and a zero
trigger is off. A positive trigger always keeps a clear below it: a missing
clear sits five points under the trigger, floored at 0, and a clear at or
above the trigger is repaired. Guest and node usage, storage and Docker
defaults hold the same bound through `EnsureValidHysteresis` in
`ValidateHysteresisThresholds`, `NormalizeStorageDefaults` and
`NormalizeDockerThreshold`. The canonical per-type entries (`nvme`, `sata` and
`hdd` in `DiskFillByType`; `nvme`, `sas` and `sata` in `DiskTempByType`, the
types the thresholds page edits) keep a positive trigger and follow the same
clear rule; a non-positive trigger resets the entry to its type default,
because the page cannot switch a type off on its own. Other per-type keys are
stored as written.
The thresholds page sends `max(0, trigger - 5)`, so a 1-5% default arrives
with clear 0 and must not fall back to the factory clear. Agent defaults used
to do that, storing `{trigger: 1, clear: 75}` for a 1% Machines CPU default,
and per-type disk entries replaced the whole pair, trigger included. The
evaluator already ignored a clear at or above the trigger
(`buildCanonicalMetricSpec` drops it and the reducer then clears at the
trigger), so those alerts fired and resolved the same way; `/api/alerts/config`
served the factory clear and firing alerts reported it as `clearThreshold`. A
repaired pair above the margin, such as `{50, 80}` written through the API,
now has a real recovery band at 45 where it used to clear at the trigger.
`internal/alerts/config/normalize_low_trigger_clear_test.go` pins every
default family and both per-type maps, `TestUpdateConfigKeepsLowTriggerClearBelowTrigger` in
`internal/alerts/config_validation_test.go` pins the saved config and the
firing alert's clear level, and the low-trigger and stored-clear tests in
`internal/config/persistence_test.go` pin the written file and load repair.
### Monitor-only delivery is terminal
Monitor-only alerts remain visible, but neither a firing nor a recovery
@@ -2597,8 +2855,10 @@ Metric enablement is an explicit On/Off interaction in row, global-default,
mobile, and bulk editors. Enabled numeric inputs accept positive trigger values
only and user-facing copy must not expose the persisted disable sentinel.
Internally, the canonical `<= 0` read rule remains intact for legacy data, while
new Off actions write `-1`, so existing configuration and the API contract
continue to round-trip without making `0` a second customer-facing disable path.
new Off actions stage `-1` (saved as trigger `0` on global defaults, which the
backend otherwise resets to factory), so existing configuration and the API
contract continue to round-trip without making `0` a second customer-facing
disable path.
Within the Proxmox tab, render-heavy ownership now further routes through
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
@@ -2745,6 +3005,22 @@ derived alert read-model and Last 24 Hours stat refresh for
shared acknowledgement owner instead of keeping its own alert mutation fork.
Future overview action behavior should extend that shared acknowledgement hook
instead of putting acknowledge mutations back into render shells.
Overview ages and bands read the shared clock. `AlertOverviewAlertCard.tsx`
passes its own `useRelativeTimeNow` reading as `now` to
`formatAlertOverviewStartedAgo` and `getMetricAlertPresentation`, and the Last
24 Hours count in `useAlertOverviewState.ts` reads the same clock, so all three
measure from the wall clock. The hook no longer exposes a minute `tick`: read
as "now", that tick measured a card mounted between ticks from up to a minute
earlier, so an alert raised 70 seconds ago read "this minute", an alert
raised since the last tick stayed out of the 24h count until the next one, a
reading already past the 10-minute stale cut-off led the card as live, and the
stale flip and 24h drop landed up to a minute late. A mounted card now turns stale,
and an alert leaves the 24h count, within one 30-second tick with no data
change. The delivery diagnoses refresh is a server read and keeps its own
minute interval rather than following the 30-second clock.
`AlertOverviewAlertCard.clock.test.tsx` pins the mid-tick mount, the stale flip
and the 24h drop under fake timers, and `useAlertOverviewState.test.tsx` pins
the diagnoses cadence.
Render-heavy alert overview ownership now routes through
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
@@ -3088,6 +3364,76 @@ ownership is `internal/alerts/alert_identity_migration_test.go`,
`internal/monitoring/monitor_alert_override_migration_test.go`, and
`frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts`.
### Alert config snapshots are owned copies
`Manager.GetConfig()` returns `AlertConfig.Clone()`, a typed deep copy that
shares no map, slice or pointer with the live config (custom rule filter
values are copied as decoded JSON values). Callers encode, persist
and edit that snapshot outside `m.mu`: the GET config handler, alert
activation, the identity migration, backup evaluation and diagnostics. It used
to be a shallow copy, so `SaveAlertConfig` normalizing it in place, or
`UpdateConfig` re-normalizing the overrides of a re-applied snapshot, wrote the
live maps while evaluation or another encoder read them, and Go aborted Pulse
with a concurrent map write. `SaveAlertConfig` also clones before normalizing,
because a config just handed to `UpdateConfig` is the live config. Persistence
keeps an explicit `0` type-level delay as no delay on save and load, matching
`NormalizeTimeThresholds`. It used to store and load it as the 5-second factory
delay, which the noisy-gauge rule stretches to 300 seconds for memory and
temperature, and the shared map write was what made the running manager agree.
Pulse saves never wrote a `0`, so only a newly sent or hand-edited `0` changes
meaning. `internal/alerts/config/clone_test.go`
fills every field and fails when a new reference field is not copied.
Regression ownership is `internal/alerts/resolved_lock_discipline_test.go` and
`internal/config/persistence_alert_ownership_test.go`.
### Alert evaluation owns the thresholds it normalizes
Host, guest and Docker container evaluation copy what they read from
`m.config` while holding `m.mu`, and only then fill in defaults.
`CheckHost` and `CheckGuest` clone a per-disk
override's `Disk` threshold before `ensureHysteresisThreshold` sets a missing
`Clear` to the trigger minus 5. They used to normalize the live override's
pointer after unlocking, so a `GetConfig()` clone under the read lock raced
the write, and the saved override changed to whatever the last poll filled in.
The live override now keeps the clear it was saved with, and the config API
returns that value. Docker container evaluation resolves its thresholds from
cloned `DockerDefaults` plus the override under the same lock, and reads the
restart-loop and memory-limit settings under the lock. It used to point into
`m.config.DockerDefaults` with no lock while `UpdateConfig` replaced
`m.config`. The test-only `getThresholdForMetricFromConfig`, which normalized
in place, is gone. `internal/alerts/alerts_test.go`
`TestAlertEvaluationLeavesTheLiveConfigToTheLock` runs host, guest and Docker
evaluation against `GetConfig()` and `UpdateConfig(GetConfig())` under
`-race`.
### Config-save reevaluation judges a filesystem alert by its own threshold
`UpdateConfig` re-judges every active alert, and a filesystem usage alert's
threshold resolves through the same per-filesystem helper its evaluator uses.
`hostDiskUsageThresholdNoLock` resolves an agent filesystem
(`agent:<host>/disk:<label>`): its own override, then the `DiskFillByType`
threshold for a hardware type inferred from the device while the host's disk
alerting is on, then the host's disk threshold. `guestDiskUsageThresholdNoLock`
resolves a guest filesystem (`<guestID>-disk-<key>`): its `guest-disk:`
override, then the guest's disk threshold. `CheckHost` and `CheckGuest` call
them under the read lock, and `resolveHostAlertThresholdsNoLock` and
`resolveGuestAlertThresholdsNoLock` call them for reevaluation. Both return an
override or per-type threshold as an owned copy. A guest filesystem alert
resolves its guest's threshold, override and identity-based custom rules by the
canonical `instance:node:vmid` guest ID that `parseGuestAlertIdentity` reads
from its resource ID. Custom rules that filter on live metrics still see no
readings there, as for every reevaluated guest alert. Reevaluation used to
judge agent filesystems by the host threshold, and guest filesystems without
their `guest-disk:` override or the guest's own override, because the
filesystem's resource ID names no guest. Any settings save resolved an alert a
per-disk threshold of 70 raised at 80 under a default of 90, notifying
recovery, and the next poll raised it again as a new alert.
`internal/alerts/threshold_resolution_shared_test.go`
`TestConfigSaveJudgesFilesystemAlertsByTheirOwnThreshold` covers an agent
filesystem override, an NVMe fill threshold, a guest filesystem override and a
guest override: an unchanged save must keep the alert, and a save lifting the
trigger above the reading must resolve it.
### Versioned alert-intent policy
The alerts runtime owns one versioned alert-intent document and its durable
@@ -2090,6 +2090,20 @@ payload shape change when the portal presents compact client rows.
index; clients must treat the endpoint's `accepted:true` as inbox
receipt, not delivery. Browser clients must use the shared
`AIChatAPI.steerSession(sessionId, { prompt, clientMessageId })` helper.
Every Assistant session mutation sub-resource under
`/api/ai/sessions/{id}` (`/abort`, `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`) accepts only `POST`. Any other method that reaches the
handler returns `405` with `Allow: POST` through
`requireAssistantSessionMutationMethod` in `internal/api/ai_handler.go`,
before the session service is resolved (earlier layers still answer
first: `Router.ServeHTTP` returns `200` for `OPTIONS` preflight, and
auth, scope, CSRF, and demo-mode guards keep their own refusals). The
session router dispatches these sub-resources by path alone, and the
demo-mode read-only guard and the CSRF check both treat `GET` and `HEAD`
as reads, so the method contract belongs to the mutating handler rather
than to its callers. `GET /api/ai/sessions/{id}/messages` stays the read
path, and the unsupported `/diff`, `/revert`, and `/unrevert` handlers
keep answering `501` for every method that reaches them.
OpenCode-style file diff/revert session routes are deliberately not part
of Pulse's supported Assistant session contract: Pulse sessions do not own
local code-file edits, and infrastructure mutations must be reviewed
@@ -3326,6 +3340,7 @@ a new API state machine, queue contract, or verification-accounting field.
action artifact so the Assistant runtime can refresh finding and action posture
from IDs and safe summaries instead of relying on pasted chat text
and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair
and the Patrol findings list the state hook reads from `aiIntelligenceStore.patrolFindings`, so `usePatrolIntelligenceState.ts` does not re-derive page-level severity tallies from it: the hook's unread `summaryStats` tally has been removed, the hook derives no client-side severity tally from that list today (the backend's per-severity `findings_count` reaches it only inside the AI summary payload it passes through), and the Activity tab's `This week` card takes its open-by-severity counts from `GET /api/ai/patrol/digest` instead of recounting that list
and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops
and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' optional `buildResourceHref` input, so a surface that needs resource-filter links passes them into the shared card instead of rebuilding infrastructure URLs inline; the cards carry no link default since the cross-resource drilldown links were retired, and render resource labels as plain text
@@ -3696,6 +3711,10 @@ a new API state machine, queue contract, or verification-accounting field.
requested metric filters through the shared guest-chart batch loader in
`internal/monitoring/monitor_metrics.go` instead of fetching the full guest
metric set and trimming after the API payload is already assembled.
`ChartsAPI.getInfrastructureSummaryCharts` is the only frontend client for
`/api/charts/infrastructure`; the deprecated `getInfrastructureCharts`
alias, which could not pass `metrics`, is removed and must not return as a
second unfiltered entry point.
39. Keep the retired compact dashboard overview route absent from that same
shared API surface. `internal/api/resourceapi/resources.go`,
`internal/api/router_routes_monitoring.go`, and
@@ -4842,6 +4861,10 @@ transition and evidence times) from the frontend-primitives shared
relative-time clock. That is presentation over the same `GET
/api/ai/patrol/status`, run history and `GET /api/ai/patrol/attention/{id}`
payloads: no field, request or polling cadence changes.
The attention detail's suppression `expiresAt` and a finding's `remind_at` and
`snoozed_until` from `GET /api/ai/patrol/findings` are future times, so the
page presents them as countdowns from the same clock ("in 1d") instead of
ages. That is presentation only: no field, request or polling cadence changes.
### Resource-list facets preserve scoped navigation evidence
@@ -7119,12 +7142,14 @@ introducing VMware-only mention payloads or provider-local inventory reads
under `/api/vmware/*`. Runtime-specific container/app mentions remain shared
unified-resource mentions as well; VMware network inventory does not create a
provider-local mention family.
That same `/api/ai/chat` payload boundary owns per-request execution-mode
overrides. Dashboard Pulse Brief and other scoped handoffs may include
`autonomous_mode:false` on the chat request to force approval-required command
execution for that exchange, but the transport must treat the field as a
request override only and must not mutate the user's persistent AI control
setting.
That same `/api/ai/chat` payload boundary owns request-local execution mode,
and the server sets it. `ChatRequest` in `internal/api/ai_handler.go` has no
`autonomous_mode` field, so the `autonomous_mode:false` that scoped handoffs
such as an explain-this-issue request still send is dropped at decode, and the
handler passes approval-required mode into the chat service for every
exchange; an Autonomous control level runs as Controlled for that request.
That clamp is request-local and must not mutate the user's persistent AI
control setting.
That same chat transport boundary owns new-session anchoring. When the request
omits `session_id`, the handler may generate and stream a session ID
immediately so the browser can anchor the visible turn, but that generated ID is
@@ -7178,7 +7203,7 @@ not serialize the model-only `handoff_context`, runtime failure detail, action
preflight/result bodies, remediation descriptions, raw commands, or approval
command payloads, and it must not preserve Patrol-authored next-step
recommendation fields from legacy handoffs.
Patrol finding handoffs are stricter than ordinary chat requests: when a request
Patrol finding handoffs keep that clamp on their own path: when a request
carries a non-empty `finding_id` or resolves to model-only Patrol briefing,
resource, or action context, `internal/api/ai_handler.go` must clamp the
request-local autonomous mode to false even if the caller supplied
@@ -7641,6 +7666,34 @@ read-only demo account. That same hidden read-side boundary includes `GET` and
`HEAD` reads for `/api/admin/users` and manual discovery at `/api/discover`;
public demo mode may block writes generically, but it must not reveal that
admin-user inventory or manual-discovery read routes exist.
The same policy hides the Go runtime profiling family for every method: on a
`DEMO_MODE` instance the demo guard answers `/debug/pprof`, `/debug/pprof/`
and every path below it with `404`, ahead of route authentication.
`registerDebugRoutes` gates those handlers on `RequireAdmin` plus a
`settings:read` scope that session logins skip, so a demo that signs visitors
in as the configured admin would otherwise hand every visitor goroutine and
heap dumps, the process command line, and CPU profiles, execution traces and
forced garbage collection that cost the whole process. `PULSE_PPROF_DISABLED`
still removes the routes on any instance.
`TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute` fails when a literal
route the parsed router files register under `/debug/`, with or without a
method prefix, escapes that policy for any common method.
`TestContract_DemoModeHidesPprofFromDemoAdminSession` in
`internal/api/contract_test.go` signs in as that admin through the full
router, proves the same session still reads `/debug/pprof/cmdline` below the
demo guard, and gets `404` through it for eight pprof paths with `GET`,
`HEAD`, `OPTIONS` and `POST`.
That generic write block keys on the request method alone. The demo
middleware admits `GET`, `HEAD` and `OPTIONS` plus the login, logout, AI
execute and OIDC login/callback allowlist; HTTP/1.1 websocket handshakes for
`/ws` and `/api/agent/ws` are `GET` requests and connect through that read
branch, so an `Upgrade: websocket` or `Connection: Upgrade` header never
carries a `POST`, `PUT`, `PATCH` or `DELETE` past the guard. A demo deployment that signs
visitors in as the configured admin, as the public demo does, has only this
guard between that session and admin writes such as `/api/system/mock-mode`.
`TestContract_DemoModeUpgradeHeadersDoNotExemptWrites` in
`internal/api/contract_test.go` signs in as that admin through the full router
and pins both halves.
That shared infrastructure install boundary now also preserves copied shell
command payload continuity: any privilege-escalation wrapper applied at
`frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
@@ -9645,6 +9698,32 @@ narrator must fail closed: nil provider, parse failure, timeout, or
empty response causes the engine to fall back to the heuristic
narrative without surfacing the AI failure to the caller, so reporting
is never blocked by AI availability.
Only `POST /api/admin/reports/generate` may hand the tenant's AI narrator to
the engine, because a narrated PDF makes a paid provider call and appends a
`cost.UsageEvent` to the cost ledger. The single-resource handler accepts
`POST` with a JSON body (the `generate-multi` decoding rules: 1MB cap, unknown
fields and trailing payload rejected, through `decodeReportingRequestBody`) and
`GET` with the same fields as query parameters, and answers any other method
with `405` and `Allow: GET, POST`. GET never calls `resolveNarrator`, whose
`GetAIService` can construct a tenant AI service (and with it list provider
models or start background discovery): it narrates through `getReportNarrator`,
the heuristic summary whose `Disclaimer` replaces the "Configure Pulse
Assistant" tip with a note that AI narration needs POST, and reads Patrol
findings through `SetExistingFindingsResolver`, which `router.go` backs with
`AISettingsHandler.ExistingAIService` so only an already running service is
used. GET passes the demo-mode guard and the CSRF check, and a SameSite=Lax
session cookie rides a cross-site top-level GET navigation, so a link must not
be able to spend AI budget. The reporting catalog advertises the transport as
`performanceReport.singleResourceMethod` (`POST`), and the settings UI follows
it. `generate-multi` and the manual schedule run endpoint
(`POST /api/admin/reports/schedules/{id}/run`) were already POST-only; due
schedules run from the server's scheduler, not an HTTP request.
`TestContract_SingleReportAINarrationRequiresPOST` pins the rule on a real
engine: GET, HEAD, PUT and DELETE make no narrator call and no AI service
resolution, while POST makes one of each.
`TestContract_SingleReportGETDoesNotConstructTenantAIService` drives
`Router.wireReportingAIResolvers` with an uncached tenant: GET constructs no
tenant AI service and POST resolves one.
Report alerts keep the alert engine's resolution. The node, VM and
container enrichers in `internal/api/metrics_reporting_handlers.go` build
every row through `reportActiveAlertInfo` and `reportResolvedAlertInfo`,
@@ -10270,6 +10349,22 @@ boundaries. Alert evaluation after that save remains alerts-owned and must use
the persisted trigger/recovery pair plus a valid derived critical threshold;
the API must not invent a TrueNAS-specific threshold sidecar.
`PUT /api/alerts/config` replaces only the top-level keys the body carries
and keeps the stored value of every key it leaves out, so a client that does
not know a setting cannot turn it off by leaving it out: the thresholds page
has no flapping-detection or alert-TTL controls, and an older browser bundle
or API script predates later fields. A sent key replaces its stored value
whole, so `"overrides": {}` removes every override rather than merging, and
the merged config then goes through the same normalization as any update (for
example, activation state is never cleared). The read, merge and apply run
under the alert manager's lock, so two concurrent partial saves cannot revert
each other in memory; the handler runs one save at a time and persists a
snapshot of the applied config, so successful saves through this endpoint
reach `alerts.json` in the order they were applied. A body that is not a JSON object, `null` included, is a 400 and changes
nothing. `TestUpdateAlertConfig_KeepsStoredValuesForUnsentKeys`
and `TestUpdateAlertConfig_RejectsNonObjectBody` in
`internal/api/alerting/alerts_test.go` prove the transport.
### Alert intent and UDP availability transport
`GET /api/alerts/intent-policies` and
@@ -6101,6 +6101,14 @@ summaries whose scope, format, and attachments the server fixes. The delivery
surface (`/api/admin/reports/schedules`) and tenant-local storage are
unchanged.
### Provider docs name POST for single-resource reports
`docs/MSP.md` and its shipped copy now give `POST /api/admin/reports/generate`
for per-client single-resource reports and say a `GET` with query parameters
still returns the report without the Pulse Assistant narrative, matching the
server, which lets only `POST` narrate. Install, upgrade and provider bundle
delivery are unchanged.
### Convergence log-reader compatibility (5 September 2026)
Scheduled reconciliation run 33939926237 failed when `gh api` refused terminal
@@ -294,6 +294,14 @@ expose only a raw reason. A same-VM fresh observation withdraws the notice and
updates values without remounting the row. Permission, disabled-agent and actual
unavailable cases retain their own explanations.
When a linked Pulse agent's filesystems fill in for a VM with no Proxmox guest
filesystems (`disksFromAgent`), the reading follows that agent instead of the
Proxmox reason: current while it reports, last known once it is stale, and
unavailable once the guest is stopped. `getWorkloadGuestDiskRead` in
`workloadGuestPresentation.ts` owns that rule for the workload row's disk cell,
the guest drawer's filesystem rows and its History, so the three cannot
disagree about one guest; `workloadGuestPresentation.test.ts` pins it.
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
`StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
@@ -2116,6 +2124,18 @@ the same record; the table renders defaults once, in both desktop table and
narrow card layouts, and per-section metric columns must resolve to metric
keys the shared column normalizer produces so the defaults editor reads and
writes the same record keys the section persists.
A global default that another setting decides while it is unset passes a
`globalDefaultFallbacks` entry for that metric (label, title, whether the other
setting is itself off, and the value switching on stages when it is). The
TrueNAS Disks temperature uses it to read `By type`, following Disk
temperature by type, with `features/alerts/thresholds/trueNASDiskTemperature.ts`
giving each disk row its type's trigger from the unsaved editor state.
`resolveAlertResourceGlobalDefaultCell` decides the cell for both layouts:
unset follows the fallback, switching on from Off returns to unset unless the
fallback is off, and an unset default with no fallback stays Off as the engine
reads it. `ResourceTable.tsx` hands its props to the shared table state through
getters, so the Custom badge and reset control follow defaults that load after
the table mounts.
Platform sub-routes that add native provider inventory must stay on the shared
platform page and table primitives. The vSphere Networks surface routes through
`/vmware/networks`, the shared platform tab model, the command palette
@@ -3011,9 +3031,9 @@ Agent`), with the plain-language source phrase available through accessible
than receiving an unfiltered copy of the estate. Opaque unified-resource ids
are not part of that visible search vocabulary and must not retain a node
when the normalized guest table has no corresponding match.
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header, or a workload group header on a surface that enables inline group drawers (the Proxmox and VMware surfaces pass `groupNodeDrawerMode="disabled"` today), must scope the matching rows through that same shared contract instead of forking a page-local summary filter path. No summary card or chart strip renders today, and the bridge carries no chart-hover input; one that returns must consume that same resolved scope, and the chart under the pointer must enter its hovered series into `resolveSummaryScopeState` as a preview input that outranks row hover instead of keeping a chart-local hover island. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
9. Keep any page summary page-scoped when table rows enter contextual focus. No infrastructure, workloads, or storage summary cards render today. If one returns, route-backed row selection may add a focused label and shared series emphasis, but the summary must continue to render the page-level series set instead of collapsing down to the selected row or replacing the global trend view with row-local empty states.
10. Keep contextual row focus on the shared primitives. Same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for local scroll preservation, off-screen reveal, inline-detail lookup, and deliberate inline-detail reveal, and must resolve the active series through `frontend-modern/src/components/shared/summaryCardInteraction.ts` behind `frontend-modern/src/components/shared/summaryTableFocus.ts`, instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface. `frontend-modern/src/components/shared/summaryTableFocus.ts` measures and scrolls rows only for deliberate focus, a focused row's inline detail or a pinned group header, so it must not keep window-level scroll or resize listeners or re-measure the hovered row on hover changes and scroll frames; an off-screen affordance for the active row needs a rendered consumer before that cost returns.
11. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. Group-scoped preview and pin must use that same shared presentation boundary: child rows that belong to a hovered or pinned summary group should expose `data-summary-group-member-active="preview|pinned"` so the block-level emphasis stays subtle, consistent, and reversible instead of each table inventing its own outline, badge, or full-strength fill treatment. Static grouped row headers on workloads, infrastructure, storage, recovery, and future grouped tables must use `frontend-modern/src/components/shared/groupedTableRowPresentation.ts` plus the `.grouped-table-row` CSS contract in `frontend-modern/src/index.css`, rather than rebuilding local `bg-surface-alt` variants with subtly different light/dark behavior or page-local left-accent markers. That shared grouped-table primitive owns the subgroup cell padding, typography, small metadata, and badge treatment as well as the row background token, so a future adjustment to the subgroup visual language changes every grouped product table from one owner. Inline table detail rows on platform, workload, and infrastructure tables must compose `frontend-modern/src/components/shared/InlineDetailTableRow.tsx` for the full-width row, surface-alt cell, detail padding, and row-click containment instead of rebuilding page-local `TableRow` / `TableCell` / `div` shells around each drawer. Storage-backed reusable row presenters under `frontend-modern/src/features/storageBackups/` must also keep row height and alert accents on class/data-attribute presentation instead of runtime inline style maps, so the shared table contract stays CSP-safe on both steady-state and alert-highlighted routes.
12. Keep retained-value data loading honest at the ownership boundary. Helpers
that prevent a feature surface from falling through the app-level Suspense
@@ -3860,22 +3880,19 @@ production table, router and styles; it does not qualify full-app scrolling.
finding-presentation destinations such as the Patrol provider-settings
route, while `patrol_autonomy` and legacy Pro activation URLs remain parser
aliases only and verified review links use the plain Patrol history anchor.
UI surfaces must not duplicate the `patrolControlStarter` query string or
write Patrol control or legacy entry-point starter telemetry from local
click handlers.
The Patrol finding-to-Assistant handoff has one owner,
`frontend-modern/src/components/AI/FindingsPanel.tsx`; the Patrol state
hook keeps no second copy of it, and the Patrol workspace passes no
handoff callback because nothing on the page reacts to a handoff.
19. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
20. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and keep focused-entity detail in the hover tooltip instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or adding persistent card chrome that steals heatmap space. The card body must stay overview-first; the tooltip may carry the active entity identity, current value, and peak, shared tooltip shells must follow semantic surface tokens instead of forcing a dark palette in light mode, the tooltip header must let long entity names consume the available width before truncating rather than clipping against an arbitrary fixed label cap, numeric metric readouts such as `16.9 MB/s` or `37.4 MB/s` must stay single-line instead of wrapping the unit onto a second row, and density-map detail that cannot fit cleanly inside the canonical tooltip shell must be omitted rather than introducing tooltip-specific chrome or a secondary chart inside the hover surface.
UI surfaces must not duplicate the `patrolControlStarter` query string or
write Patrol control or legacy entry-point starter telemetry from local
click handlers.
21. Keep retired self-hosted hosted-model and trial acquisition surfaces out of
normal v6 GA runtime. Shared shells and helper-driven badges may continue to
parse legacy payload fields, but ordinary self-hosted Assistant, Patrol, and
settings flows must present provider setup as BYOK/local/self-managed and
must not surface hosted-model credits, in-app trial starts, or generic
managed-model claims.
22. Keep sparkline scrubbing source-local and sibling-sync timestamp-based. The chart a user is actively scrubbing in `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/useInteractiveSparklineState.ts` must keep its dashed hover cursor on the real local mouse `x`, while sibling cards may map the shared hover timestamp onto their own timelines. Shared cursor sync must not snap the source chart back onto the nearest sample timestamp, the rendered SVG/canvas hover cursor must bind to the actual numeric cursor coordinate rather than a boolean guard state, the time cursor must span the chart viewport instead of collapsing to the series height, and the hover tooltip must track the pointer instead of anchoring to the chart top edge while following the active theme rather than a hardcoded dark shell. The hover tooltip must stay side-offset from the active scrub cursor and flip to the available side near viewport edges so it does not cover the highlighted guide or graph point.
23. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
24. Keep shared infrastructure/resource selectors on the canonical agent-facet
truth. Shared primitives and settings-facing selector helpers must treat
@@ -3905,12 +3922,14 @@ production table, router and styles; it does not qualify full-app scrolling.
26. Keep the authenticated app root aligned with that same first-session path.
That same shared-primitive ownership now includes contextual row focus.
`frontend-modern/src/components/shared/contextualFocus.ts` is the canonical
owner for interactive-series filtering, focused-label lookup, active-series
resolution, and nearest-scrollable-ancestor preservation across page-scoped
summary surfaces. Dashboard row focus, infrastructure summary emphasis,
storage summary emphasis, and workloads summary emphasis must all route through
that helper instead of maintaining page-local copies of the same hover/focus
rules.
owner for nearest-scrollable-ancestor preservation, off-screen element
reveal, and inline-detail lookup and reveal, while page/group/entity scope
resolution belongs to
`frontend-modern/src/components/shared/summaryCardInteraction.ts` behind
`frontend-modern/src/components/shared/summaryTableFocus.ts`. Workloads and
Storage row focus and the Proxmox page's node-to-guests reveal must route
through those helpers instead of maintaining page-local copies of the same
hover/focus rules.
`frontend-modern/src/App.tsx` must land authenticated `/` and `/login`
handoffs through this subsystem's provider-first platform landing contract:
the first visible provider/runtime platform wins, and the Machines surface
@@ -4830,6 +4849,13 @@ performance-report transport (`/api/reporting` and `/api/reporting/generate-mult
so the reporting panel does not go dead on mixed-version installs, but that
compatibility path is intentionally report-only and must not invent the newer
catalog-owned VM inventory export surface.
`reportingPanelModel.ts` picks the single-resource transport from the catalog's
`performanceReport.singleResourceMethod`: `POST` sends the request fields as a
JSON body, which is the only transport the server lets narrate a PDF with Pulse
Assistant, and `GET` sends the same fields as query parameters.
`reportingCatalogModel.ts` reads a missing field as `GET`, the only method a
backend that predates the field accepts, and the legacy compatibility catalog
names `GET` for `/api/reporting`. Fleet reports stay `POST` on every backend.
`ReportingPanel.tsx` must therefore treat `vmInventoryExport` as optional when
it renders a governed reporting catalog. A legacy compatibility catalog with no
inventory export still owns a valid enabled reporting surface and must continue
@@ -4902,6 +4928,16 @@ or not fully verified.
The same empty-state helper must consume Patrol trust-history evidence so a
historical regression reads as history review context, not as a current issue
and not as a healthy all-clear.
`frontend-modern/scripts/canonical-platform-audit.mjs` carries no Patrol
summary-card rule. Its `canonical-patrol/no-local-summary-card-presentation`
regex matched only text shaped like the deleted summary card's tint ternaries
on `summaryStats().criticalFindings`, `warningFindings` and `fixedCount`. The
Patrol state hook's `summaryStats` accessor has had no reader since that card
was deleted, so the accessor and the rule are both gone.
`frontend-modern/src/utils/patrolSummaryPresentation.ts` is off the audit
allowlist too: it now holds only the recency and verification presenters,
which no audit rule needs to exempt, so the audit scans it like any other
module.
The Patrol page renders no summary shell today. If one returns, the same
hierarchy applies inside it: once its primary assessment strip states Patrol's
current risk and verification basis,
@@ -5109,52 +5145,27 @@ Enter/Space work through the native button, and lifecycle actions do not bubble
into collapse. Generic `Ask Assistant` and `Copy context` actions do not belong
in object drawer headers: Assistant remains available through the global shell,
and raw context export must not compete with the operational reading path.
The shared interactive sparkline now follows that same split.
`frontend-modern/src/components/shared/InteractiveSparkline.tsx` stays the
render shell, `frontend-modern/src/components/shared/useInteractiveSparklineState.ts`
owns hover state, RAF throttling, canvas draw scheduling, and resize lifecycle,
and `frontend-modern/src/components/shared/interactiveSparklineModel.ts` owns
sparkline downsampling, gap segmentation, axis-tick math, and hover-selection
policy. Future sparkline work should extend those owners instead of pushing
canvas scheduling or chart-shape math back into the shared component shell.
That same sparkline boundary now also owns floating tooltip shell routing:
local hover tooltips must derive viewport anchor coordinates from the shared
runtime/model path, keep the tooltip beside rather than on top of the scrub
cursor, and render through
`frontend-modern/src/components/shared/TooltipPortal.tsx`, not as HTML
`foreignObject` shells inside the `preserveAspectRatio="none"` chart SVG where
cross-browser scaling can stretch the tooltip surface or drop its semantic
shell styling.
That same shared sparkline boundary now also owns active-series isolation
metadata. The shell may expose `data-active-series-display` and
`data-rendered-series-count` for proof and inspection, but only the shared
runtime/model owners may decide whether a hovered or focused series is merely
emphasized or fully isolated; feature shells must not fork their own row-hover
line filtering.
`frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx` and
`frontend-modern/src/features/proxmox/BackupActivityChart.tsx` render their
hover tooltips through `frontend-modern/src/components/shared/TooltipPortal.tsx`
rather than as HTML `foreignObject` shells inside the chart SVG; the mini
sparkline's SVG uses `preserveAspectRatio="none"`, where cross-browser scaling
can stretch an embedded tooltip surface or drop its semantic shell styling.
The retired dashboard overview route must not regain feature-local trend,
KPI, problem-resource, or card shells. Workload-table and guest-row fallback
copy that lives under `frontend-modern/src/components/Workloads/` must keep
using `frontend-modern/src/utils/workloadEmptyStatePresentation.ts` and
`frontend-modern/src/utils/workloadGuestPresentation.ts`. New route-level empty
states, tone mapping, or compact issue copy must extend the shared
`emptyStatePresentation`, `semanticTonePresentation`, and
`problemResourcePresentation` helpers instead of reviving deleted
dashboard-only KPI, metric, storage, recovery, or trend presentation helpers.
states or tone mapping must extend the shared `emptyStatePresentation` and
`semanticTonePresentation` helpers instead of reviving deleted dashboard-only
KPI, metric, storage, recovery, problem-resource, or trend presentation
helpers.
That shell must also stay passive with respect to data ownership: future
overview trend cards may render summary-range controls and operator-facing
empty or error copy only after they have a governed owner, and they must not
reintroduce route-local metrics-history fetch loops for CPU and memory
sparklines; the infrastructure and workloads summary chart routes already own
that chart contract.
The shared density map now follows that same owner split.
`frontend-modern/src/components/shared/DensityMap.tsx` stays the render shell,
`frontend-modern/src/components/shared/useDensityMapState.ts` owns hover
signals, canvas draw lifecycle, and resize handling, and
`frontend-modern/src/components/shared/densityMapModel.ts` owns bucket/window
math, hover target selection, focused-series tooltip detail, and density-cell
opacity rules. Future density-map work should extend those owners instead of
pushing canvas lifecycle, tooltip shaping, or chart math back into the shared
shell.
The shared trial banner is retired for self-hosted v6 GA. Future commercial
notification work must start from the explicit Plans, hosted, activation,
recovery, or support surfaces rather than reviving a global authenticated-shell
@@ -5272,7 +5283,19 @@ too. Every read of the clock returns the wall clock; the 30-second tick only
tells readers to re-read, so a cell that mounts between ticks never measures
from a stale time and a timestamp from the last few seconds never reads as a
future time.
The rule has one deliberate exception. The age of a latest reading (last used,
Future times have their own formatter. `formatRelativeTime` is past-only: it
reads a time ahead of `now` as "just now" (compact) or "0s ago", which is right
for clock skew on something already observed and wrong for an expiry, a
reminder or a schedule.
Those go through `formatTimeUntil` (`frontend-modern/src/utils/format.ts`),
which counts down ("in 3h", "in 1d"), rounds to the nearest unit so a duration
just chosen reads as chosen, and returns `dueText` (default "now") once the
time arrives. A countdown target does not change while its surface stays open,
so the countdown passes `now` from the shared clock, as the Patrol suppression
expiry and the Patrol findings reminder and snooze lines do. The replication
Next sync column and the Patrol header's next-check `CountdownTimer` keep their
own minute- and second-precision countdowns.
The relative-age rule has one deliberate exception. The age of a latest reading (last used,
last seen, last success, last checked) on data the surface reads once and does
not re-read stays the age at read time, because a moving age over a snapshot
that never refreshes claims the reading stopped when it may not have. Such a
@@ -5787,7 +5810,15 @@ the surface-alt detail row shell locally. The content shell must clip
horizontal paint below the large breakpoint without becoming a scroll
container, reset the parent table's `whitespace-nowrap` inheritance, and allow
its descendants to shrink, then restore visible overflow for the static
desktop layout. Long operator-state copy must wrap inside the shared row border
desktop layout. Below that breakpoint the default content shell is capped at
the table scroll shell's content-box inline size (`max-w-[100cqi]`; the shell
is a size container), never a viewport estimate of the page chrome, so a phone
drawer does not leave an empty strip beside its content (the retired
`100vw-3.5rem` cap left 27px at 390px) and is never wider than the table's
visible area. Callers that pass their own `contentClass` own their width.
Fixed-layout cells clip overflow, so the sticky offset is inert and horizontal
scrolling still moves the drawer with its cell.
Long operator-state copy must wrap inside the shared row border
instead of painting beneath adjacent controls or disappearing at the clip edge.
When focused detail content is removed, `InlineDetailTableRow` restores focus
to its current `aria-controls` disclosure with `preventScroll`; live refresh,
@@ -6172,14 +6203,6 @@ when local login is hidden behind SSO, and the `/docs/...` public-route contract
keeps that destination readable without an authenticated session. The login
surface must remain guidance-only: it may not expose secrets, imply an email
reset flow, or create a browser-side authentication bypass.
The shared summary strip primitives now follow that same owner split.
`frontend-modern/src/components/shared/SummaryPanel.tsx` and
`frontend-modern/src/components/shared/SummaryMetricCard.tsx` stay the render
shells for summary-frame spacing and card density, while monitoring surfaces
such as recovery, infrastructure, workloads, and storage only choose from the
owned shared density modes instead of forking summary spacing with feature-
local padding hacks. Future summary-density work should extend those shared
primitives rather than hard-coding compact card chrome inside one surface.
The shared tooltip now follows that same owner split.
`frontend-modern/src/components/shared/Tooltip.tsx` stays the render shell and
singleton API boundary, `frontend-modern/src/components/shared/useTooltipState.ts`
@@ -6378,11 +6401,6 @@ That same shared table boundary now owns CSP-safe fill rendering for metric
bars: `frontend-modern/src/components/shared/ProgressBar.tsx` must render fill
width through DOM attributes rather than inline width styles that break the
public demo CSP.
That same shared-boundary rule applies to summary density. The shared compact
mode on `SummaryPanel.tsx` and `SummaryMetricCard.tsx` exists for genuinely
dense monitoring surfaces, but pages that are trying to align with the normal
Pulse monitoring scan path should stay on the default shared density instead of
using page-local compact overrides by habit.
That same recovery shell boundary now also owns one canonical top-level filter
controller in
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`. Route-backed
@@ -6440,20 +6458,6 @@ rollups, points, or facets payloads arrive, it must keep that selected
platform present in the option set so the shared `LabeledFilterSelect` shows
the owned value immediately instead of flashing back to `All Platforms` until
recovery data warms.
`frontend-modern/src/utils/problemResourcePresentation.ts` now also belongs to
that same dashboard overview boundary so the problem-resource severity contract
stays shared with `ProblemResourcesTable.tsx` instead of floating as an
unowned helper.
Problem-resource table readability belongs to that same owner. Repeated rows
may collapse only when they share the same governed display label, resource
type, and problem signal; the header count and Pulse Brief counts must continue
to represent the underlying affected resources, and grouped links must route to
the broad owning surface rather than inventing a synthetic resource target.
Problem Resources and Pulse Brief wording must not amplify generic
status-shaped names such as `storage (offline)` into first-viewport prose or
grouped-row sublabels; when the resource name is only a type plus status, the
surface should summarize the type-level issue in operator language instead of
repeating raw backend-shaped labels.
The retired dashboard action queue must not be reintroduced as a compact
Patrol or infrastructure issue panel. Patrol-owned runtime findings remain
governed by `frontend-modern/src/utils/aiFindingPresentation.ts` and their
@@ -6663,6 +6667,11 @@ icon/content layout, and action-link chrome. The
outdated-sensor notices, and the
`platform-inline-notice-local-amber-shell` pattern guard blocks future
`platformPage` files from reintroducing page-local amber notice shells.
A notice's render predicate reads what its page hydrates on every tab the
notice renders on. `collectOutdatedSensorSetupNodes` lists the PVE nodes that
carry the registry's `proxmox.sensorSetupOutdated` verdict and takes no disk
rows, because only the Proxmox Storage tab loads them; a predicate over them
left the notice silent on Overview.
Alert incident-event filter containers, labels, and chips must now route
through the shared presentation helpers in
@@ -7172,6 +7181,12 @@ shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while
alert stats, filtered ordering, and single/bulk acknowledge runtime behavior.
Future overview control flow should extend that hook rather than restoring
action timers or acknowledge mutations to the tab shell.
The overview follows the shared-clock rule for relative ages: the alert card
reads `useRelativeTimeNow` for its started age and the live reading's stale
cut-off, and the hook reads it for the Last 24 Hours count. A hook-local
minute signal used as "now" is a table-local interval under that rule; the
hook keeps a minute interval only to refresh delivery diagnoses, a server read
whose cadence is not tied to the age clock.
Render-heavy overview ownership now lives in
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
@@ -7894,10 +7909,12 @@ renders above the chart.
TrueNAS disk rows follow it too: the drawer's Temperature row reads
"(last known)" with the reason as title and no heat tone, and the one-line
summary leaves a retained reading out because it has no room for the reason.
The Proxmox outdated sensor setup notice (`features/platformPage/sensorSetup.ts`)
counts a SATA or SAS disk as having a temperature only when the reading is
current, so a retained one cannot hide the notice for a disk Pulse cannot read
now.
The Proxmox outdated sensor setup notice reads the registry's per-node
`proxmox.sensorSetupOutdated` verdict, which counts a SATA or SAS disk as having
a temperature only when the reading was collected now
(`diskinventory.TemperatureCollected` in
`internal/unifiedresources/proxmox_sensor_setup.go`), so a retained one cannot
hide the notice for a disk Pulse cannot read now.
The focused browser proofs are
`frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts`,
@@ -1825,6 +1825,29 @@ boundary around it:
matches node names only, so it relies on fixture node names being unique
across instances, which they are.
`checkPhysicalDiskAlerts` also hands each disk's temperature to
`CheckProxmoxDiskTemperature`, so a Proxmox disk that only the node's sensors
report alerts under the disk temperature policy, as its Running Hot verdict
already says it should. It passes the reading only when this poll collected it
(`collectedPhysicalDiskTemperature`, `diskinventory.TemperatureCollected`), so
a retained last-known value holds the alert rather than judging it, and it
passes `AgentSMARTReported`, which `mergeHostAgentSMARTIntoDisks` sets on a
disk a still-reporting linked agent lists in its SMART report (disks built
from that report when the Proxmox query fails go through the same merge). A
disk the agent lists is the agent's: `CheckHost` raises its temperature
alert, and the PVE disk check closes its own as moved to the agent, so no
disk alerts twice. Once the agent's lease lapses (`ExpireHostTelemetry` marks
the host offline) its retained rows still enrich the disk, but ownership
returns to the PVE check, which judges the node's own current readings; an
agent newly linked to a disk with an open PVE alert takes it over on the next
disk poll. An excluded device closes its temperature
alert with the health and wearout ones. Fixture disks carry no agent SMART
merge, so on a mock estate every hot disk on an online node alerts.
`TestMergeHostAgentSMARTIntoDisksMarksDisksTheAgentReports` and
`TestCheckPhysicalDiskAlertsRaisesProxmoxDiskTemperatureAlerts` in
`internal/monitoring/physical_disk_roundtrip_test.go` pin the marker,
the collected reading, agent ownership and exclusion.
Switching mock mode fences the alert evaluations that read mode-dependent data
(`mockModeFence`, `internal/monitoring/mock_mode_fence.go`). `GetState`, the
fixture graph, the unified read view, the recovery rollups and the connection
@@ -1899,11 +1922,33 @@ a live report or poll already past its mock-mode check when mock mode is
switched on can still evaluate after that clear, and its alerts can persist
until mock mode is left.
The fence and the clear are per monitor while the mode is process-wide. A
tenant monitor other than the one `SetMockMode` was called on neither drains
nor clears, so fixture alerts it raised outlive the switch as they did before
the fence; switching mock mode with several tenant monitors running is not
covered.
The mode is process-wide while each monitor keeps its own fence, alert manager,
fixture agents and state, and the server runs one monitor per organization, so
`SetMockMode` switches every running monitor, whichever tenant's monitor it is
called on (the mock-mode API calls the requesting tenant's, the demo-fixture
licence sync the default organization's). `Start` joins a process-wide set of
running monitors under `mockModeSwitchMu`, and leaves it when its loop returns.
A switch flips the mode once, then ends each monitor's epoch, waits for its
admitted calls, clears its alerts, forgets its fixture agents when leaving mock
mode and resets its state, and only then restarts the mode-dependent runtime of
each monitor in the set. `Start` sets its runtime context before it joins and
chooses the mock metrics sampler or discovery while it joins, under the same
lock, so no switch lands between reading the mode and starting the runtime for
it, and a switch that reaches a monitor not yet joined leaves the runtime to
its `Start`. A monitor that missed switches between `New` and `Start` first
leaves what it holds: `New` records the mode and the number of switches made so
far, and `Start` compares both, so even a round trip that brought the flag back
drops the fixture alerts a read path raised meanwhile, as well as the live
alerts `New` restored outside mock mode. A monitor that is not running is
switched only when the switch is made through it; a call for the mode the flag
already holds still moves any reached monitor whose alerts belong to the other
mode, and leaves the rest alone.
Release builds reach several tenant monitors in mock mode only on an instance
running `DemoMode` with the `demo_fixtures` entitlement (release
`ValidateEnablement` refuses mock fixtures otherwise) and with more than one
organization's monitor running; the licence sync switches only for the
default organization.
The proofs park a real evaluation at a fixed point, switch modes and release
it. `TestLeavingMockModeRefusesTheRestOfAnInFlightPass` holds a mock pass at its
@@ -1933,7 +1978,25 @@ previous-state carry returns fixture nodes or guests. All eight are in
`TestStoreRefreshTakesItsMockModeScopeBeforeReadingState` in
`internal/monitoring/canonical_guardrails_test.go` rejects a call that reads
state in an argument ahead of `m.mockModeFence.begin()` and pins the
end-epoch-then-clear order in `SetMockMode`.
end-epoch-then-clear order in `endMockModeEpoch`, which `SetMockMode` runs for
every monitor it reaches.
`TestLeavingMockModeClearsEveryRunningTenantMonitor` and
`TestEnteringMockModeClearsEveryRunningTenantMonitor` start the default and one
other tenant monitor through `MultiTenantMonitor`, wait until both have joined,
raise alerts in both, switch through one of them and check both. Against a
switch that reached only its own monitor, the default monitor kept its fixture
alerts (69 to 104 across runs) and all 69 fixture agents after leaving mock
mode through the other tenant, and that tenant kept its live alert after mock
mode was entered through the default monitor. `TestMonitorStartedAfterAMockModeSwitchLeavesTheModeItWasBuiltIn`
fails if `Start` joins without leaving the mode its monitor was built in, and
`TestSetMockModeAlignsAMonitorWhoseModeTheFlagAlreadyHolds` fails if a call for
the flag's current mode returns without moving such a monitor, and
`TestMockModeSwitchLeavesAnUnjoinedMonitorsRuntimeToItsStart` fails if a switch
starts the runtime of a monitor whose `Start` has not joined, and
`TestMonitorStartedAfterAMockModeRoundTripDropsWhatItRaisedMeanwhile` fails if
`Start` compares only the mode. All six are in
`internal/monitoring/monitor_mock_alerts_test.go`.
The disk evaluation runs on the mock alert tick, not the poller's disk
interval (five minutes by default, `PhysicalDiskPollingMinutes` per instance).
@@ -2636,7 +2699,18 @@ truthfulness, not native thaw, containing-release or workload acceptance.
ingestion so canonical host IDs stay stable across restarts (see the
unified-resources contract's durable identity-pin obligation). Rebuild
paths added to the adapter must keep that persistence step; ephemeral
snapshot-bridge adapters stay read-only.
snapshot-bridge adapters stay read-only. The rebuild, the live
supplemental refresh and the read-state overlay ingest records with the
adapter's configured stale thresholds, because record ingest joins
operator links and the freshness gate of every metric merge, a link's or
a source's into an existing row, reads them (unified-resources contract,
"Operator links reach record-ingested resources"). Regression
coverage:
`TestMonitorAdapterJoinsLinkedRecordsWithConfiguredStaleThresholds` and
`TestMonitorAdapterSourceMergesUseConfiguredStaleThresholds` in
`internal/unifiedresources/monitor_adapter_read_state_test.go` and
`TestManualLinkToSupplementalGuestHoldsWithAndWithoutContinuity` in
`internal/monitoring/issue1913_host_continuity_test.go`.
11. The TrueNAS provider projects pools with `Storage.Topology` fixed to
`pool` and the ZFS data vdev layout in `Storage.VDevLayout`. The
@@ -3175,7 +3249,20 @@ Monitoring retains one current status per target and location, using server
receipt time for remote freshness while keeping the agent-authored observation
time as evidence metadata. A stale or disconnected agent path derives as
indeterminate at read time; slow or fast agent clocks cannot manufacture or
conceal a disconnect. Aggregation is conservative: all reachable paths are
conceal a disconnect. A local path beside other locations that has not run
within the same window derives as indeterminate too ("no recent local check").
The target takes its freshness from its newest location, so otherwise a
failing remote report kept an old local pass counted as reachable and the
check read available with current evidence. For the same reason an available
check's evidence lasts only as long as its newest reachable path, not a
fresher path that failed. When the external-probe entitlement lapses, a
single remote result stands in for the local location until the local poller
runs, with the earlier of its agent check time and the server's receipt time
as its check time, so a fast agent clock cannot keep it current. An aggregate
over several locations never stands in: a target without a retained local
observation reads as never checked, and not connected, until the local poller
runs.
Aggregation is conservative: all reachable paths are
healthy, mixed reachable and failed/unknown paths are degraded, all current
paths unreachable are unavailable, and no reachable path with incomplete or
indeterminate coverage is unknown. Only aggregate unavailability advances the
@@ -4051,6 +4138,14 @@ ambiguous. Direct SATA, SAS, and NVMe device fallback IDs retain their legacy
shape, while multiple controller members behind one block path add their
controller target to the fallback identity. Per-member I/O must never inherit
an aggregate controller counter.
A linked node's agent with no SMART reading for a device, such as a member's
standby row, files that device's I/O under the Proxmox disk's metrics target
(`proxmoxPhysicalDiskMatchesForLinkedNode` reads `MetricResourceID`). For a
member merged with its Proxmox row that target is the member's own SMART key,
scoped to the member once (`PhysicalDiskMetaMetricID`), so its SMART and I/O
history share one series. The target used to carry the member topology twice,
and I/O already stored under that doubled key is left to age out rather than
migrated. Proof: `TestIdentitylessControllerMembersReadTheirWritersHistory`.
The same rules apply to SATA and NVMe inventory: direct-disk source IDs keep
their historical shape, controller-member IDs add their member target, and
cross-source correlation is scoped to the canonical parent node. A successful
@@ -4430,6 +4525,13 @@ disk usage when the last VM snapshot is still recent guest-agent truth rather
than an already carried-forward fallback. That keeps transient guest-agent or
status-call failures from regressing a VM back to misleading allocated-disk
data while still avoiding indefinite replay of stale disk summaries.
The previous VM that `previousVMFromView` builds from the unified read state
carries disk usage only when it is Proxmox's own reading. When a manually
linked Pulse agent's disk filled in because Proxmox had no guest filesystems
(`VMView.DiskFromLinkedAgent`), carrying it would relabel the agent's value as
a Proxmox `prev-` read, and with Proxmox outranking the agent on guests that
copy would then freeze over the agent's live disk.
`TestPreviousVMFromViewKeepsLinkedAgentDiskOutOfProxmoxCarry` pins both owners.
That compatibility boundary also applies to historical snapshot labels that may
still exist in tests, live in-memory state, or pre-canonical diagnostic paths:
legacy aliases such as `rrd-available`, `rrd-data`, `node-status-available`,
@@ -4737,6 +4839,37 @@ disk history model, and mock seeding plus live mock ticks in
`internal/monitoring/mock_metrics_history.go` must append to that same disk
timeline instead of creating a second drawer-only or mock-only disk history
path.
The SMART-resolved id is the serial the disk resource carries: when a SMART row
reports none, `HostSMARTDiskMetricID` takes the one the host's Unraid inventory
reports for that disk, as the unified-resources adapter does, unless the Unraid
row does not describe the disk (`unraidDiskDescribesSMARTRow`: a controller
member, or several SMART rows on the disk's device path), and
`hostDiskIOMetricResourceID` keys
a device with no non-standby SMART row by its Unraid serial
(`HostUnraidDeviceMetricID`, which refuses a device several Unraid rows name)
before the linked Proxmox node's disks and the `<host>:<device>` fallback.
Controller members sharing a device path keep distinct keys, so the I/O
writer's aggregate-device guard, which compares every row on the path by its
own identity (standby rows and legacy `sdc [sat]` labels included), files
their counter under no member.
Proof:
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports` and
`TestHostDiskIOMetricResourceIDFallbacks`.
The agent's `smart_temp` series holds the reading the disk resource shows,
not only the SMART row's own. `writeHostSMARTMetrics` writes
`HostSMARTDiskTemperature`, which falls back to the host's Unraid inventory
reading when the row has none, and `writeHostUnraidDiskTemperatures` writes
`HostUnraidDiskTemperature` under `HostUnraidDiskMetricID` for each Unraid row
whose key is not one of the host's SMART rows' keys, such as a `--disk-exclude`
member, so an Unraid reading never overwrites a SMART row's own at the same
timestamp.
Unraid rows sharing a key are one disk in the registry, which shows the latest
of their readings, so the last row with a reading decides that key's single
sample. `diskinventory.TemperatureCollected` keeps a spun-down disk's or an
expired host's leftover reading out of history.
Proof:
`TestAgentDiskChartsTheUnraidTemperatureItShows` and
`TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART`.
That same monitoring-owned disk-health boundary also includes shared storage
risk assessment in `internal/storagehealth/`. When providers or host agents
emit structured storage topology such as Unraid per-disk state, the shared
@@ -1512,15 +1512,29 @@ provider/settings affordances.
The feature surface now also keeps the same shell/runtime split internally:
`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` owns feature
composition, the Patrol-owned section files under
`frontend-modern/src/features/patrol/` own the header, banner, summary, and
workspace render surfaces, and
`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` owns Patrol
state, transport, polling, and effect lifecycle. The shell and section surfaces
must not re-accumulate Patrol API calls, timer orchestration, or store refresh
semantics, and `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` now
`frontend-modern/src/features/patrol/` own the header, banner, workspace, and
tab-panel render surfaces (the Inbox attention list, Protection objectives, and
the Activity tab's `This week` digest and recent work), and
`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` owns the
page's shared Patrol state, transport, polling, and effect lifecycle. The shell
and the header, banner, and workspace surfaces must not re-accumulate those
Patrol API calls, timer orchestration, or store refresh semantics; the
attention list, objectives, recent work, and `This week` panels each own their
own typed read and refresh instead, and `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` now
owns the canonical summary normalization so Patrol consumers inherit one
governed recent-change and policy-posture snapshot instead of reintroducing
hook-local fallback logic.
The hook no longer returns the summary card's severity and fix tallies. Its
`summaryStats` accessor (critical, warning, and total active findings plus
fixed outcomes, counted client-side over the Patrol findings list) lost its
only reader when the Patrol summary card was deleted and has been removed,
together with the `canonical-patrol/no-local-summary-card-presentation` rule in
`frontend-modern/scripts/canonical-platform-audit.mjs`, whose regex matched only
text shaped like the deleted card's tint ternaries on
`summaryStats().criticalFindings`, `warningFindings` and `fixedCount`. The open-issue severity counts the page shows today come from
the Activity tab's `This week` card, which reads them from
`GET /api/ai/patrol/digest` (see that card's section below); a count surface
that returns must not revive a hook-local tally of the findings list.
That same Patrol hook boundary now consumes shared AI settings/model truth
through `frontend-modern/src/stores/aiRuntimeState.ts` instead of mounting its
own `/api/settings/ai` or `/api/ai/models` reads. Patrol-specific state still
@@ -2687,6 +2701,36 @@ clock while it stays open. Its `Last seen` and flapping window are latest
readings of a record that may have moved since, so they stay the ages at
read time, and the queue rows, summary, recent work, run history and digest
re-derive on their own 30- or 60-second re-reads.
Future times in Patrol count down instead of reading as ages, because the
past-only `formatRelativeTime` reads each of them as "just now" for their whole
duration. The attention detail's suppression line counts down to the expiry
with the exact time on hover and names the state expiry restores in the
early-end control's words, from the same
`getAttentionSuppressionEndPresentation` split on
`operationalRecord.acknowledgement`: an open issue reads `Returns to active
attention in 1d.`, one marked reviewed reads `Suppression ends in 1d. It stays
reviewed.` (expiry restores Acknowledged, which is not active attention), and a
passed expiry reads `Suppression has ended.`
(`getAttentionSuppressionExpiryLabel`). The findings list reads `Reminding in
6d` for a will_fix_later reminder and `snoozed, returns in 3h` for a snooze,
both through `formatTimeUntil` on the shared clock with the exact time on
hover. A reminder reads `Reminder overdue` once the panel's overdue id list
holds it. That list is sampled on the shared clock and is what the Overdue
commitments chip and filter count, so the label, chip and filter agree: a row
mounted or re-read between the deadline and the tick that adds it reads
`Reminder due`, and a row already on screen keeps its last countdown until that
tick. The list only notifies when its ids change, so a clock tick that moves no
deadline past now does not rebuild the open Overdue rows. The hourly reminder
sweep then brings the finding back whether or not it still trips. An ended
snooze reads `snooze ended` until the next findings re-read drops the snoozed
status: `GET /api/ai/patrol/findings` sends no status, so the frontend
`aiIntelligence` store (`normalizeFindingStatus`) derives `snoozed` on each
read only while `snoozed_until` is ahead, and the finding then shows as Active
unless it has since resolved or been dismissed.
`getFindingReminderPresentation` and `getFindingSnoozePresentation` in
`aiFindingPresentation.ts` own the findings copy.
`PatrolAttentionWorkbench.test.tsx`, `FindingsPanel.links.test.tsx` and
`FindingsPanel.test.ts` pin it.
### This week card answers what Patrol did for the customer
@@ -1507,7 +1507,6 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
default filter-option labels must flow through the shared all-option
presentation helper via the workload filter config model instead of the
hot-path shell hard-coding local `All ...` strings.
28. Keep summary-card hover emphasis on one bounded rendering budget: when a summary row is active, shared sparkline and density-map primitives must promote the selected series and demote background series through the same active-series ID rather than layering a second page-local highlight pass, so zoom-range and hover scrubbing stay visually coherent without reintroducing multi-series overdraw on the hot summary cards. Density maps on that hot path must stay overview-first under focus: preserve the multi-entity heatmap rows, layer focused-entity detail inside the card, and avoid swapping transient hover into a separate single-series chart path.
29. Keep public self-hosted checkout handoff endpoints on the adjacent
commercial/router boundary, not the summary-chart hot path. When
`internal/api/router.go`, `internal/api/router_routes_cloud.go`, or
@@ -1529,7 +1528,8 @@ them with new hot-path fetches. Node rows that draw infrastructure history
must read the canonical infrastructure-summary route owned by
`internal/api/router_routes_monitoring.go` and `internal/api/router.go`, today
through `frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts`;
storage summary cards must use the storage summary cache owners. The authenticated
the storage pool Growth column must read through the storage summary cache
owners (`useStorageSummaryCharts.ts` and `storageSummaryCache.ts`). The authenticated
root path in `frontend-modern/src/App.tsx` must not prewarm a deleted
dashboard-specific overview, trend, or summary transport.
The same hot path must keep mock/demo chart identity on the canonical
@@ -2262,22 +2262,11 @@ Route-backed workload `resource` focus on that hot path is contextual state
only, not inferred filter state: opening or closing an inline drawer must not
invent, retain, or clear `agent` or node-scope filters unless those filters
were already explicit in the managed workload URL.
That same hot-path ownership now covers top-of-page summary emphasis: infrastructure
and workloads summary cards must treat row hover, chart hover, and route focus
as one shared active-series contract so time-range switches, row scrubbing, and
chart cross-highlighting reuse one existing chart path instead of repainting
page-local “selected row” overlays on top of already downsampled summary
history. Hovering a sparkline or density map for one entity must promote that
entity into the shared active series so sibling cards highlight the same object
at once rather than maintaining chart-local hover state, and the synchronized
hover timestamp must remain visible across those sibling cards even when the
active entity has no samples for one metric in the current range. Those
sibling cards should expose the synchronized value through one compact
header-level readout, not by spawning duplicate floating tooltips on every
chart.
Infrastructure cluster-header hover now belongs to that same bounded hot path:
hovering a grouped infrastructure header must scope the top cards to that
cluster's unified-resource members through the shared group/entity interaction
That same hot-path ownership covers summary emphasis. No top-of-page summary
cards render on infrastructure or workloads today; if they return, row hover,
chart hover, and route focus must stay one shared active-series contract that
reuses one existing chart path instead of repainting page-local “selected row”
overlays on top of already downsampled summary history.
That same hot path now also owns dashboard freshness discipline. Supported
unified-resource dashboard reads may hydrate from REST for first paint, but
once websocket `state.resources` is available they must consume that canonical
@@ -2414,21 +2403,20 @@ must not tighten physical retention until the durable downgrade state reaches
its governed day-60 purge timestamp. Re-upgrade clears that ceiling before the
next retention run. The ceiling may only reduce configured retention; it must
never expand an operator's shorter storage policy.
contract instead of inventing an infrastructure-local summary filter branch.
For shared line charts on that hot path, the shared sparkline primitive may
isolate the selected series inside the existing render budget, but that
isolation must still reuse the same summary series set and timeline data rather
than triggering a second page-local chart recomputation.
That same hot-path rule now covers contextual row focus on those pages.
That same hot-path ownership now also forbids hover-driven table movement.
When a summary chart promotes one active entity, the matching row may highlight
in place if it is already mounted and visible, but the hot path must not
auto-scroll the page or rebuild the table into a one-row filtered view on
transient hover. Off-screen reveal must stay behind an explicit `Jump to row`
action routed through the shared summary-table focus bridge.
That same hot-path contract also owns the row-emphasis paint. Dashboard guest
rows and infrastructure resource rows must expose summary-linked activity
through the shared `data-summary-row-active` marker and let the shared frontend
That same hot-path ownership forbids hover-driven table movement on the
workloads and storage tables. Row hover may highlight the matching row in
place, but the hot path must not auto-scroll the page or rebuild the table
into a one-row filtered view on transient hover; only a deliberate row focus
may reveal an off-screen row, through the shared summary-table focus bridge.
The bridge measures row position only inside that deliberate reveal. It must
not keep window-level scroll or resize listeners, or re-measure the hovered
row on every hover change and scroll frame, for an off-screen affordance that
nothing renders: the retired `Jump to row` visibility check did exactly that,
costing 128 `getComputedStyle` calls across eight row hovers and 320 across
ten scroll steps on the 12-node mock storage table, and none once removed.
That same hot-path contract also owns the row-emphasis paint. Workload guest
and group rows and storage pool, group, and disk rows must expose
summary-linked activity through the shared `data-summary-row-active` marker and let the shared frontend
primitive render the emphasis, instead of layering lane-local row-fill classes
that diverge across pages or wash out inline metric bars.
`frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts` must
@@ -2436,14 +2424,7 @@ write workload selection back into the workloads route through the shared
same-path route-state scheduler, but the actual shell-position handoff for
query-only row focus must go through `frontend-modern/src/utils/appShellScrollRestoration.ts`
plus the root `frontend-modern/src/App.tsx` shell so opening a focused
workload does not look like a full page reload, and the governed infrastructure and
workloads summary surfaces must keep the summary page-scoped while that focus
reuses the shared highlight contract; density maps may retain page-level
context, but they must now also surface focused-entity detail inside the same
card instead of dimming the rest of the map into unusable background noise or
swapping the hot path into a transient single-series chart. Line-card
isolation must still flow through the shared sparkline runtime instead of a
page-local focus overlay. The shared same-path scheduler must also own cleanup
workload does not look like a full page reload. The shared same-path scheduler must also own cleanup
for every deferred scroll-restore timeout and animation frame it creates, so
route-state cleanup cannot leave hot-path replay work running after the owning
surface unmounts.
@@ -2452,12 +2433,13 @@ focus may mount drawer-local facet and intelligence fetches, but those
requests must retain the mounted page shell through the shared non-suspending
query helper instead of bubbling a transient `Loading view...` fallback through
`AppLayout.tsx` when an inline infrastructure drawer opens.
That same hot-path rule now has one shared runtime boundary. Interactive-series
filtering, active-series derivation, focused-label lookup, and local
scroll-preserving row focus must extend
`frontend-modern/src/components/shared/contextualFocus.ts` instead of
rebuilding page-local `Set` scans or scroll repair logic in dashboard,
infrastructure, or workloads hot paths.
That same hot-path rule now has one shared runtime boundary. Local
scroll-preserving row focus and inline-detail reveal must extend
`frontend-modern/src/components/shared/contextualFocus.ts`, and active-series
derivation must stay in
`frontend-modern/src/components/shared/summaryCardInteraction.ts` behind
`summaryTableFocus.ts`, instead of rebuilding page-local `Set` scans or scroll
repair logic in workloads, storage, or platform-table hot paths.
That same hot-path ownership now also covers deliberate inline-detail reveal.
When a focused workload or infrastructure row opens its inline detail, the hot
path may preserve scroll across same-route state writes, but the actual reveal
@@ -2471,8 +2453,9 @@ fold, marking that movement as deliberate so route-state restore does not
replay over it and then scrolling only enough to keep the row header plus the
top of the detail visible instead of hard-centering every expansion.
That same hot-path ownership now includes summary cache invalidation. A
summary chart cache that hydrates charts for fast remounts, today the storage
page's module-scoped `frontend-modern/src/utils/storageSummaryCache.ts`, must
summary history cache that hydrates fast remounts, today the storage page's
module-scoped `frontend-modern/src/utils/storageSummaryCache.ts` behind the
pool Growth column and its sort, must
carry the chart contract version in its key and advance it when the summary
chart timeline contract changes, so a hot-reloaded or long-lived browser tab
cannot rehydrate an older series set before the next live fetch completes.
@@ -2720,6 +2703,41 @@ must remain distinguishable without hover. Workloads supplies this row metadata,
not local table CSS or a duplicate renderer; non-filesystem rows keep the compact
default. Verification covers the opt-in at this composition boundary and actual
long-path wrapping in the shared renderer's browser proof.
A guest's linked Pulse Agent can stop reporting while Proxmox keeps polling the
VM. `useWorkloads` copies `resource.agent.stale` onto the row as `agentStale`,
and sets `disksFromAgent` when Proxmox reported no guest filesystems and the
agent's own `disks` filled in. When that agent is stale, `GuestDrawerOverview.tsx`
reads the Guest RAID card as last known through the shared `RaidCard`
`lastKnownReason` prop with `HOST_AGENT_STOPPED_REPORTING_REASON`, puts a status
line over muted Network rows, reads agent-supplied Filesystems rows through the
existing `last-known` disk read state, and marks the Pulse Agent row as stopped
reporting. Agent-supplied filesystem rows follow the agent's freshness rather
than the Proxmox `diskStatusReason`, which describes Proxmox's own guest reads,
so a live agent's rows keep their usage and bars. Those rows read as
unavailable once the guest is stopped, whether its status or the Proxmox
`vm-stopped` reason says so, and Proxmox's own guest filesystems keep their
Proxmox read state. The two flags ride the existing row mapping and
add no query or poll; `GuestDrawer.test.tsx` and `useWorkloads.test.ts` pin
both sources. The workload table's disk cell (`useGuestRowState.ts`,
`GuestRow.tsx`) and the drawer History (`guestDrawerModel.ts`) judge the same
guest through the drawer rows' rule, `getWorkloadGuestDiskRead` in
`workloadGuestPresentation.ts`: a silent agent's bars carry the Last known
label, a live agent's bars stay current under Proxmox `agent-not-running`, and
History reads the aggregate disk metric as current, last known or unavailable
the same way. The rule covers the aggregate because Proxmox records no guest
disk usage without guest filesystems, so a manually linked guest then keeps the
agent's disk metric, and the poller no longer carries that agent reading
forward as Proxmox's own `prev-` read (see the monitoring contract).
`TestResourceRegistry_ManualGuestAgentLinkDiskMetricFollowsProxmoxGuestFilesystems`
pins that merge; `GuestRow.test.tsx` and `GuestDrawer.test.tsx` pin the cell and
History. On the websocket path (`resourceStateAdapters.ts`) a native Proxmox VM
facet's new guest-read outcome drops filesystems it no longer reports, a native
agent facet without `disks` drops the agent's, and the agent facet always
carries `stale`, so the rows switch source and lose the Last known label when
the agent resumes, without a reload. A stale Proxmox source
can still lose the aggregate to a live agent while its own filesystem rows
remain; such a guest stays on the Proxmox rule. The agent's OS, addresses and
physical disk health carry no stale cue yet.
Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency
boundary for collapse: Workloads owns which inline row is selected and the
close handler, while
@@ -2767,7 +2785,7 @@ the filter and presentation rails wrap-capable instead of forcing a single
no-wrap row that clips trailing actions. The workload shell leaves Type,
Status, dynamic scope, and contextual `Clear filters` actions on
the primary filter rail. Durable presentation choices — grouped/list mode,
bars/trends, chart visibility, Guest/Host memory basis, and Columns — pass
bars/trends, Guest/Host memory basis, and Columns — pass
their panel content through `FilterBar.viewOptions` instead of importing or
wrapping `ViewOptionsDisclosure` in the workload feature or rendering as
equally prominent filter toggles. The shared FilterBar owns the inline View
@@ -2790,10 +2808,13 @@ label so the values are not an orphaned set of durations. Columns must expand
as a full-width responsive checkbox row inside the View disclosure instead of
opening a nested absolute panel or a tall single-column desktop list. On
Proxmox, the non-default Host basis must remain visible in the workload memory
column header after the View disclosure closes. Workload chart
visibility remains a display preference: its menu action must expose explicit
`Show charts` / `Hide charts` pressed state, and hiding charts must remove the
summary section rather than leaving an empty collapsed summary band on screen.
column header after the View disclosure closes. No workload summary chart
section renders today, so the chart-visibility control in
`frontend-modern/src/components/Workloads/WorkloadsFilter.tsx` stays hidden
(no surface passes `onChartsToggle`). If a summary section returns, that
control must expose explicit `Show charts` / `Hide charts` pressed state, and
hiding charts must remove the summary section rather than leaving an empty
collapsed summary band on screen.
The Workloads-owned filter-config assembly now lives in
`frontend-modern/src/components/Workloads/useWorkloadsState.ts`, so future
filter runtime changes must extend through those owners instead of
@@ -3025,6 +3025,18 @@ Removing the disconnected recorder does not alter alert, action approval or
operator authority. An unrelated organization receives no default archive
fallback.
### Report GETs cannot spend AI budget
`Router.wireReportingAIResolvers` in `internal/api/router.go` wires a second
reporting resolver, `SetExistingFindingsResolver`, backed by
`AISettingsHandler.ExistingAIService`.
A `GET /api/admin/reports/generate` uses only that resolver and the
deterministic narrator, so it makes no AI provider call, writes no cost-ledger
entry and never constructs a tenant AI service. GET passes the demo-mode guard
and the CSRF check, and a SameSite=Lax session cookie rides a cross-site
top-level navigation, so AI narration of a single-resource report now requires
a CSRF-checked `POST`. No authentication, scope or session rule changed.
### Node setup action v7 compatibility
Workflow consumers pin `actions/setup-node` to
@@ -944,6 +944,15 @@ Patrol coverage, schedule, finding, and approval state belongs on Patrol-owned
surfaces or explicit Patrol affordances; Proxmox overview must not treat it as
backup coverage, restore readiness, PBS verification/protection proof, or a
replacement for the Proxmox Backups tab and workload Backup column.
The outdated sensor setup notice above the Proxmox tabs is temperature
guidance, not storage or recovery evidence. It reads the registry's per-node
`proxmox.sensorSetupOutdated` verdict from the node rows the active tab already
loads (Overview, Storage, Replication and Backups load nodes; Ceph and Mail do
not, so it does not render there), so the Overview query must not add
`physical_disk` rows to feed it; the derived `ProxmoxData` flag opens no storage
or recovery path.
`ProxmoxPageSurface.contract.test.tsx` pins the notice on Overview with no disk
rows and an Overview query without `physical_disk`.
The Proxmox inline estate totals are likewise an adjacent frontend-primitives
and unified-resource projection, not backup or recovery evidence.
`ProxmoxPageSurface` must flow the already-loaded workload inventory into the
@@ -1404,6 +1413,12 @@ state only. A restored prompt or restored message count may help an operator
continue a protected-item investigation, but it must not become backup coverage
evidence, recovery freshness, restore entitlement, storage-owner identity,
approval policy, or a provider-local recovery command handoff.
The POST-only guard on Assistant session mutations (`/abort`,
`/summarize`, `/fork`, `/undo`, `/redo`, `/steer`; `405` with `Allow: POST`
for any other method that reaches the handler) is transport hardening in
`internal/api/ai_handler.go`.
It opens no storage, backup, or recovery read or write path, and a rejected
request never reaches session state.
Approved Assistant tool execution through `internal/api/router_routes_ai_relay.go`
is also adjacent API/AI action plumbing for storage/recovery consumers.
`AssistantToolExecutor` / `ApprovedAssistantToolExecutor` may execute an already
@@ -2770,11 +2785,9 @@ canonical pre-handler authorization proof covering both transfer routes.
top-level GitHub install.sh SERVER installer, so a correct local script
(signed or not) is always preferred over a wrong-identity proxied one on the
unverified curl-piped-into-bash agent path (issue #1470).
15. Keep storage summary chart identity and sticky-shell behavior on the
shared storage path. Pool rows, disk rows, storage summary cards, and
storage detail charts must all address history through the canonical
unified-resource metrics-target IDs, and the storage page must reuse the
shared sticky summary primitive instead of a storage-local scroll wrapper.
15. Keep storage chart identity on the shared storage path. Pool rows, disk
rows, the pool Growth column, and storage detail charts must all address
history through the canonical unified-resource metrics-target IDs.
Expanded pool and physical-disk details must also follow the canonical
object-detail information architecture: current configuration, topology,
health, and SMART facts land on `Overview`; stored capacity and I/O charts
@@ -2820,10 +2833,9 @@ canonical pre-handler authorization proof covering both transfer routes.
likewise native Assistant tool-visibility plumbing, not MCP transport
state and not storage/recovery execution authority.
When operators hover or focus pools versus physical disks, the storage
summary must reuse one resolved active-series ID across card state and
chart highlighting so pool-only cards demote cleanly during disk focus and
disk-temperature cards demote cleanly during pool focus, instead of
leaving stale row-local IDs or storage-local hover branches on the page.
page must reuse one resolved active-series ID for row emphasis in both
views instead of leaving stale row-local IDs or storage-local hover
branches on the page.
Any page, group, or entity scope that becomes pinned through storage
interaction must stay row-first: the pinned row or group remains the
visible scoped state, and explicit clearing belongs to the shared storage
@@ -2836,16 +2848,15 @@ canonical pre-handler authorization proof covering both transfer routes.
`data-summary-group-member-active="preview|pinned"` state so the grouped
block reads as one scoped set without adding storage-local outlines, pill
buttons, or heavy full-row fills.
18. Keep storage summary remount caches versioned with the chart contract.
`frontend-modern/src/components/Storage/StorageSummary.tsx` may keep a
bounded in-memory cache for same-tab remounts, but its cache key must carry
an explicit summary contract version so long-lived demo sessions do not
rehydrate stale pool or disk sparkline shapes after the storage summary
chart model changes. The shared storage history owner at
`frontend-modern/src/utils/storageSummaryCache.ts` must likewise remain
bounded to 20 recent node/range summaries, use LRU replacement, and abort
in-flight requests when organization ownership changes so a late response
cannot recreate an old-scope cache entry.
18. Keep the storage summary history cache versioned with the chart contract.
`frontend-modern/src/utils/storageSummaryCache.ts`, which
`frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` reads
for the pool Growth column, must carry an explicit summary contract version
in its cache key so long-lived demo sessions do not rehydrate stale pool or
disk series shapes after the storage summary chart model changes. It must
also remain bounded to 20 recent node/range summaries, use LRU replacement,
and abort in-flight requests when organization ownership changes so a late
response cannot recreate an old-scope cache entry.
19. Keep cross-surface workload handoffs on canonical IDs too. Shared workload
chart transport may look up provider-backed VM history through unified
metrics targets, but infrastructure/workloads/storage/recovery navigation
@@ -2997,6 +3008,15 @@ recovery evidence. Unremembered overflow deliveries remain auditable with
`reason=capacity`; cache expiry, overflow, or restart is not a successful
storage operation or a reason to delete historical events.
### Alert settings saves keep unsent keys without a new storage path
`PUT /api/alerts/config` now applies only the top-level keys the body carries
and keeps stored values for the rest, so a thresholds save no longer turns off
alert TTL cleanup. It still writes `alerts.json` through the existing
`SaveAlertConfig` path, opens no storage or recovery route, and leaves backup
and snapshot alert settings under the same replace-what-was-sent rule as every
other key. `internal/api/alerting/alerts_test.go` pins the handler.
### Container diagnostics shares private bootstrap transport (1 October 2026)
Recovery-adjacent diagnostics now reuse the canonical complete installer
@@ -3686,11 +3706,6 @@ live wire-up and persistence resync so a finding the operator
manually closed before a restart still reads as "Resolved by you"
afterward, instead of being misattributed to Pulse's auto-detection.
`StorageSummary.tsx`, `StoragePageSummary.tsx`, and `useStoragePageSummary.ts`
now surface `poolsDegraded` and `disksFailing` health indicators alongside
pool/disk counts. These additions project from existing websocket pool/disk
state; they must not introduce new API polling or widen the storage-fetch
boundary.
Agentless availability endpoints are adjacent infrastructure context only, not
storage or recovery inventory. Storage/recovery consumers may receive
`network-endpoint` resources through shared unified-resource snapshots, but
@@ -3703,9 +3718,9 @@ not Standalone Machines membership, storage ownership, repository membership,
backup coverage, or restore authority.
That same owned summary path now also runs through
`useStorageSummaryCharts.ts`: the storage page owns one page-scoped summary
range and one shared storage-summary history fetch, and both the sticky
summary cards and per-pool growth column reuse that payload instead of
forking separate row-local history reads or duplicate polling loops.
range and one shared storage-summary history fetch, and the per-pool Growth
column and its growth sort reuse that payload instead of forking separate
row-local history reads or duplicate polling loops.
The storage table's default `priority` order is a risk list: records with an
open, unacknowledged alert sort first by severity (fed into
`sortStorageRecords` through `StorageSortContext.alertWeightByRecordId` from
@@ -4343,38 +4358,26 @@ split: storage derives the health semantics through
Storage components and `features/storageBackups` presentation helpers must not
recreate raw green/yellow rounded-dot classes locally.
That same storage page ownership now also includes contextual focus behavior
for pools and disks. Expanding a storage row may set a focused metrics-target
ID for shared summary emphasis, but `frontend-modern/src/components/Storage/StorageSummary.tsx`
must keep the storage summary page-scoped instead of collapsing its sparklines
to the single expanded row or replacing the page overview with row-local empty
states.
That same page-scoped summary contract now also owns canonical hover-isolation
behavior. Pool and disk rows must publish the resolved metrics-target ID into
the shared summary contract so pool usage, used capacity, and available space
cards can isolate the active row through the shared sparkline primitive while
non-matching cards such as disk temperature demote to inactive context instead
of rebuilding a row-local summary surface.
That same shared summary contract now also owns chart-driven emphasis.
Hovering one storage summary chart must promote the same canonical metrics
target ID through sibling cards, so pool charts cross-highlight the same pool
while non-matching cards such as disk temperature demote to inactive context
instead of keeping chart-local hover state. When a sibling storage card can map
that same entity into its own series set, it must also surface the synchronized
value as a compact card-header readout instead of opening a second floating
tooltip away from the pointer.
That same storage summary contract now uses the shared contextual-focus owner.
`frontend-modern/src/components/Storage/StorageSummary.tsx` must route
interactive-series filtering, focused-label lookup, and active-series
resolution through `frontend-modern/src/components/shared/contextualFocus.ts`
so storage keeps the same page-scoped focus semantics as infrastructure and
workloads instead of preserving a storage-local hover/focus branch.
That same storage ownership now also governs summary-to-table reveal. Hovering
pool or disk charts may highlight the matching row when the active view already
shows it, but storage hover must not auto-filter or auto-scroll the table.
When the active chart entity is off-screen or hidden behind the other storage
view, the page must use the shared summary-table focus bridge and reveal the
target row only through an explicit `Jump to row` action, switching views or
expanding the owning group only for that deliberate reveal path.
for pools and disks. Expanding a pool row or selecting a disk row sets the
focused summary series ID and row hover sets the hovered one;
`frontend-modern/src/components/Storage/useStoragePageModel.ts` must resolve
both through the shared `useSummaryPageInteractionState` bridge in
`frontend-modern/src/components/shared/summaryTableFocus.ts`, so pool, disk,
and pool-group row emphasis keeps the same page/group/entity semantics as
workloads instead of preserving a storage-local hover/focus branch. The
storage summary history that `useStorageSummaryCharts.ts` loads feeds only the
pool Growth column and its sort; no storage summary card strip renders, and
one that returns must stay page-scoped instead of collapsing to the expanded
row or replacing the page overview with row-local empty states.
That same storage ownership now also governs reveal. Row hover may highlight
the matching row in place, but storage hover must not auto-filter or
auto-scroll the table. Reveal belongs only to deliberate focus: the bridge
hands the focused pool or disk series to the `revealActiveSeries` callback,
which reopens a focused pool's collapsed owning group, and a pinned pool-group
header that sits off-screen scrolls into view through the same bridge. Focus
only ever names a row in the active pools or disks view, so the callback never
switches views; the view-switching branches that served the retired jump to
the active row are gone.
That same reveal contract now also owns inline-detail expansion. When a pool or
disk row is deliberately focused and its inline detail opens on the storage
page, the detail row must publish the same canonical summary series ID through
@@ -4397,8 +4400,8 @@ instead of a separate hardcoded recovery cache, so recovery filters, rollups,
and shared route handoffs see the same platform set as settings and
infrastructure.
That same graph-owned mock boundary also owns demo-readiness for storage and
recovery surfaces. Mock summary cards, seeded history, and provider-backed
storage/recovery counts must come from the same canonical fixture graph so
recovery surfaces. Mock seeded history and provider-backed storage/recovery
counts must come from the same canonical fixture graph so
storage and recovery demos show realistic healthy-versus-attention balance
instead of blank history, stale provider context, or page-local fixture drift.
That same recovery-facing demo contract also owns subject readability. When
@@ -4407,16 +4410,11 @@ subject identity shown to operators must stay human-readable
`<cluster>/<namespace>/<pvc>` context from the canonical graph instead of
opaque hash-like IDs that break demo trust and cross-surface recognition.
That same adjacent chart boundary now also assumes seeded and live mock
storage timelines are one continuous series. Disk-temperature, pool-usage,
used-capacity, and available-space cards may consume shaped chart payloads for
presentation, but those payloads must still reflect one canonical mock metric
timeline instead of a seeded seven-day sparkline with a second live tail
stitched on afterward.
That same chart boundary now also owns row-hover summary filtering. Storage
pool and disk rows may focus the summary cards, but the shared storage summary
must filter every supported card through the same canonical metrics-target
identity rather than letting temperature, capacity, or detail cards drift onto
page-local row identifiers.
storage timelines are one continuous series. Storage chart consumers such as
the pool Growth column and the pool and disk History charts may consume shaped
chart payloads for presentation, but those payloads must still reflect one
canonical mock metric timeline instead of a seeded seven-day series with a
second live tail stitched on afterward.
That same shared `internal/api/` dependency also assumes auth-persistence
teardown is synchronous when recovery-adjacent runtimes reinitialize. Session,
CSRF, and recovery-token workers may not leave stale background goroutines or
@@ -4854,8 +4852,8 @@ the cloud-summary decision is derived from scope rather than stored as a
separate boolean flag.
The same storage-facing runtime paths now also normalize org scope through
`frontend-modern/src/utils/orgScope.ts` before building cache keys or
multi-tenant fetch state, so Dashboard, StorageSummary, and other storage
adjacent consumers do not each keep a local `getOrgID() || 'default'`
multi-tenant fetch state, so `storageSummaryCache.ts` and other
storage-adjacent consumers do not each keep a local `getOrgID() || 'default'`
fallback.
The frontend storage and recovery surfaces are also first-class embedded
@@ -4897,10 +4895,10 @@ small table chrome to return to the primary page. It must not return as an
equal-weight subtab or a top-level posture-card action strip without a separate
governed product decision.
Recovery is also intentionally outside the interactive page/group/entity
summary-card contract used by workloads, infrastructure, and storage. The
recovery route must not adopt `summaryCardInteraction.ts`, synchronized card
hover, row-driven summary scope, or shared `SummaryPanel` card framing simply
because adjacent monitoring pages use those primitives. Recovery may still show
summary-scope contract that the workloads and storage tables use. The recovery
route must not adopt `summaryCardInteraction.ts`, `summaryTableFocus.ts`,
row-driven summary scope, or a summary card strip simply because adjacent
monitoring pages use those primitives. Recovery may still show
coverage breadth and platform context inside the Protection coverage table and
event filters, but the page must read item-first so the unified recovery model
is not visually anchored to one platform family.
@@ -5191,8 +5189,8 @@ recovery-local grid overrides or width hacks. Protected-items controls should
also use the same shared `Reset all` page-controls action pattern as storage
and workloads when visible filters are active, instead of forcing operators to
clear each inventory filter manually.
That same handoff should keep Recovery out of shared summary-card density
tuning unless a new governed product decision reintroduces a first-viewport
That same handoff should keep Recovery free of a first-viewport summary card
strip unless a new governed product decision reintroduces a first-viewport
summary owner. The current route should spend its top-level density budget on
the activity section, compact controls, and one primary data card.
That same shell rule applies to the recovery-events workspace.
@@ -6267,6 +6265,19 @@ is in `applyLicensedFeatureConfigSnapshot`
(`pkg/server/telemetry_licensed_features.go`), pinned by
`TestApplyLicensedFeatureConfigSnapshot_CountsScheduledReportingAndProfiles`.
### Single-resource report AI narration requires POST
`internal/api/metrics_reporting_handlers.go` now accepts `POST` with a JSON
body for `/api/admin/reports/generate` and lets only that method use the
tenant's AI narrator; `GET` keeps working with query parameters and the
deterministic summary. A narrated PDF records the same AI cost-ledger entry as
before, and only a `POST` can now produce one. A `GET` reads Patrol findings
only from an AI service that is already running
(`AISettingsHandler.ExistingAIService`), so it no longer constructs a tenant AI
service, whose construction opens that tenant's SQLite resource export store.
Report content, backup and recovery enrichment, and tenant storage are
otherwise unchanged.
### Audit-read activity is a bounded, content-free local history
`audit_read_activity.json` follows the same shape as the existing external-agent
@@ -6886,3 +6897,31 @@ migration or recovery authority is added or moved.
The performance report and reporting runtime snapshot handlers apply the same
test before tabulating a disk temperature. That changes only which held value a
report shows, not any storage or recovery path.
### A linked agent's stale flag and guest disk owner open no storage path
`internal/unifiedresources/types.go` now always sends `AgentData.Stale`, so a
resumed agent clears its stopped-reporting mark in browsers that merge agent
facets field by field, and `VMView.DiskFromLinkedAgent` keeps the poller from
carrying a linked agent's guest disk forward as a Proxmox read. Both describe
which source's reading is current; no backup, retention, migration or recovery
path is added or moved.
### Demo write guard ignores websocket upgrade headers
`internal/api/demo_middleware.go` no longer exempts every request carrying
`Upgrade: websocket` from the demo read-only guard. On a `DEMO_MODE` instance a
`POST`, `PUT`, `PATCH` or `DELETE` with websocket handshake headers is now
judged like any other write: outside the login, logout, AI execute and OIDC
allowlist it gets the generic demo `403`, or `404` on a hidden route, and that
includes config import at `/api/config/import`. HTTP/1.1 websocket handshakes
are `GET` requests and still connect. No storage, retention, backup, migration
or recovery path is added or moved.
### Demo mode hides the pprof routes
`internal/api/demo_mode_operations.go` adds the Go runtime profiling family
(`/debug/pprof` and every path below it, every method) to the public-demo
hidden routes, so the demo guard on a `DEMO_MODE` instance answers it with
`404`. That removes a diagnostic read surface on demo instances only; no
storage, retention, backup, migration or recovery path is added or moved.
@@ -1609,7 +1609,12 @@ Source freshness is a separate unified-resource status input, not a fixed
global health timer. Snapshot rebuilds, resource seeding, supplemental record
overlays, and cloned read-state overlays must preserve the monitoring-provided
stale thresholds for Proxmox, PBS, and PMG sources so resources do not flap to
warning/degraded between successful configured poll cycles.
warning/degraded between successful configured poll cycles. A source merge
into an existing row judges metric freshness by the ingest's thresholds and
the stale pass's own threshold rule, so the merge's freshness gate never
calls a source current that the stale pass marks stale, or the reverse. The
gate decides only where no stronger rule does: a hypervisor-managed guest
keeps its platform CPU even while that source is stale.
Service-discovery readiness is a unified-resource payload contract, not a
drawer-local decoration. Resource list/detail payloads that expose a
@@ -2303,6 +2308,16 @@ evidence of what the array was doing when the agent went quiet, but without
live status colour. Where a retained rebuild percentage is shown, it reads
"Rebuild was at N%" with no speed or progress bar, and a member that was not
healthy names its state in its badge.
A Pulse agent manually linked into a guest carries the same signal there.
`AgentData.Stale` is always on the wire, because browsers merge agent facets
field by field (`resourceStateAdapters.ts`) and would keep a resumed agent's
omitted false as true; `TestAgentDataAlwaysSendsStale` pins it. That always
present `stale` also marks a native agent facet, so one without `disks` has
withdrawn them, and a native Proxmox VM facet's new guest-read outcome likewise
drops `disks` it no longer reports; either way the other source's filesystems
take over without a reload (`resourceStateAdapters.test.ts`). `VMView.DiskFromLinkedAgent` names the agent
as the owner of a guest's selected disk metric, which the monitoring poller
reads before it carries a Proxmox disk reading forward.
For SMART disk
temperatures on rows that still render, the provenance travels on
`agent.sensors.smart[].collection`: the Machines temperature cell
@@ -2803,7 +2818,9 @@ application resource-provider or WebSocket lifecycle.
ID and drops the superseded pin row (`ApplyCanonicalIDSuccessions`
in `internal/unifiedresources/canonical_id_succession.go`).
Successions never fire while the old ID still belongs to a live
resource, never follow a contradicting machine key (a reinstalled
resource or to one a manual link folded into its primary (see
"Operator links reach record-ingested resources"), never follow a
contradicting machine key (a reinstalled
machine mints fresh, it does not absorb the old host's operator
state), and never rewrite change-journal rows. Known limitation:
machine-keyless hosts whose history already merged under a collapsed
@@ -2884,8 +2901,9 @@ application resource-provider or WebSocket lifecycle.
`ResourceRegistry.IngestRecords` applies the same
`ApplyCanonicalIDSuccessions` semantics as pin-driven successions
(operator state and action audits re-key, the superseded pin row
drops, never while the old ID belongs to a live resource, journal
rows never rewritten). Regression coverage:
drops, never while the old ID belongs to a live resource or one a
manual link folded into its primary, journal rows never rewritten).
Regression coverage:
`TestRegistryIngestRecordsKeepsSameHostnameSystemsDistinct` /
`TestIngestRecordsSucceedLegacyHostnameScopedCanonicalIDs` /
`TestIngestRecordsDoNotCompleteTrueNASIdentityFromPins` in
@@ -2987,6 +3005,36 @@ or force every provider type back into the active route payload. Docker,
Kubernetes, TrueNAS, and VMware surface contract tests pin their route query
maps, and the shared hook test pins facet normalization.
### Proxmox node verdicts over disks arrive on the node
The Proxmox page hydrates one query per workflow, and only its Storage tab
loads `physical_disk` rows, so a node verdict that depends on the node's disks
is derived in the registry and carried on the node, never rebuilt in the page
from disk rows. `ProxmoxData.SensorSetupOutdated` (`proxmox.sensorSetupOutdated`
on REST and websocket rows) is that verdict for the outdated sensor setup
notice. `refreshProxmoxSensorSetupLocked`
(`internal/unifiedresources/proxmox_sensor_setup.go`) runs after
`buildChildCounts` on every ingest path and gives every Proxmox node an
explicit true or false (other resources omit it), because browser facet merges
read an omitted field as a partial snapshot and would keep an earlier true. It
is true when the node's temperature payload is available and in the legacy
`sensors -j` format (`models.Temperature.LegacySensorsFormat`) and a disk whose
canonical parent is the node has no reading collected now
(`diskinventory.TemperatureCollected`) and a type whose temperature a PVE node
gets only through SMART: `sata`, `sas`, or the `hdd` and `ssd` form factors
Proxmox's own inventory reports. Proxmox also types a non-rotational USB device
`ssd`, so a USB device without SMART counts. A retained reading does not count
as current, including one kept while a host agent reports the disk in standby;
a reading a linked host agent collects now clears the disk. NVMe disks never
set it, because kernel hwmon reports them even on a legacy setup, and a host
agent the registry has not linked to the node contributes no disks. The
verdict reflects the registry's node and disk state at each ingest.
`TestProxmoxNodeSensorSetupOutdatedFromItsDisks` and
`TestProxmoxNodeSensorSetupOutdatedFollowsWhereTheReadingArrives` in
`internal/unifiedresources/registry_test.go` pin the rule, the linked-agent
case, the explicit false on the wire and the presented JSON;
`resourceStateAdapters.test.ts` pins that a false clears a merged true.
### Canonical REST facets preserve realtime workload evidence
The frontend REST projection retains the complete source-authored Proxmox
@@ -3431,6 +3479,20 @@ It owns probe status, incidents, history, evidence, and the outgoing `checks`
relationship. The matched resource carries every correlated check in the canonical
`availabilityChecks` facet, keyed by saved target id, while `availability`
remains an additive singular compatibility summary selected from that set.
The summary is the worst check: a confirmed outage first, then any other
observed failure, then an unchecked, ambiguous or unresolved check, then a
passing one, ties going to the first saved target id. A confirmed outage is an
enabled check whose observed failures reach its failure threshold, with an
unavailable aggregate when one is reported and an outcome that is not
indeterminate. That is the gate the availability poller applies before it
raises `availability_unreachable`, so a probe agent that stops reporting keeps
its old failure count but confirms nothing. `availabilityOutageConfirmed` in
`internal/unifiedresources/availability.go` holds that rule for both the
ranking and `EvaluateResourceHealth`, which reads the summary. A confirmed
outage on any attached check therefore makes the resource's health critical
even when a check that sorts earlier has failed below its threshold, with the
`availability_failed` reason unless a critical alert explains the verdict
first.
Adding a second explicit or unambiguously correlated check must retain both
source-owned endpoint rows, project both facets onto the same resource, and
emit one `checks` relationship per target from the check to that resource.
@@ -3540,6 +3602,24 @@ remain preferred over device fallback. Direct SATA, SAS, and NVMe fallbacks
retain their historical device-scoped shape; controller members sharing one
block path add controller/target scope so they cannot collapse into one
resource. Topology correlation is parent-scoped and ambiguity fails closed.
That scope is added once. A controller member without a usable serial or WWN
keeps its history under a source ID that already names the member: the host
agent's `HostSMARTDiskMetricID`, and the Proxmox `PhysicalDiskMetricID`, which
returns `ProxmoxPhysicalDiskSourceID`. `PhysicalDiskMetaMetricID` therefore
returns a fallback that already ends in the disk's own device, controller and
target unchanged as the metrics target. It appends that topology only to a
fallback without it: a Proxmox source ID from before members were scoped,
which `PhysicalDiskMetricID` scopes the same way, or the canonical resource ID
`PhysicalDiskView.MetricResourceID` falls back to. The suffix survives a merge
because a member target only reaches a disk together with the controller the
agent reported beside it (the multiplexed smartctl path sets both), and the
Proxmox row copies both from that agent row. A registry rehydrated from
persisted resources rebuilds the agent source ID in the same scoped shape
(`seedAgentPhysicalDiskSourceIDLocked`). TrueNAS disks carry no controller
target and keep their fallback. The reader used to append the topology to an
already scoped source ID as well, so such a member's chart read a key no
writer used. Proof: `TestPhysicalDiskMetaMetricIDScopesAControllerMemberOnce`,
`TestIdentitylessControllerMembersReadTheirWritersHistory`.
Host reports, monitoring models, registry merges, typed physical-disk views,
API resources, websocket/REST snapshot merges, and storage presentation must
@@ -5313,7 +5393,8 @@ TrueNAS API and the agent on that box), and an unknown machine never splits a
disk. A split (operator exclusion) recorded against any ID the disk can hold,
unscoped, current or machine-scoped, keeps the observation on its
source-specific ID, keyed to its machine when another machine's disk already
holds that ID (agent disk source IDs are the bare serial). Once the identity
holds that ID (an agent disk's source-specific ID hashes its bare serial or
WWN, below). Once the identity
spans machines the unscoped ID no longer names one disk, so a split recorded
against it applies to every copy, erring towards an extra row rather than a
merge the operator forbade, and a manual link recorded against it stops
@@ -5331,14 +5412,113 @@ same-serial disk first appears on another machine and back when it goes away,
and alert-history rows owned under the unscoped ID do not follow a re-key; a
disk two reporters share is scoped to its canonical parent when the identity
first spans machines, which the fixed ingest order (snapshot sources, then
supplemental records) and source priority decide; agent SMART and Unraid disk
source IDs are the bare serial, so same-serial agent disks on different hosts
share one `SourceAgent` mapping and only the last ingested keeps the agent
source target; same-serial disks share one metrics history key; and across
reporters, hostnames that normalize alike (default TrueNAS names, short names
across domains) still join. `IdentityMatcher` keeps one resource per machine
ID, so the registry indexes disks by hardware key (`physicalDisksByHardware`)
to see every copy.
supplemental records) and source priority decide; and across reporters,
hostnames that normalize alike (default TrueNAS names, short names across
domains) still join. `IdentityMatcher` keeps one resource per machine ID, so
the registry indexes disks by hardware key (`physicalDisksByHardware`) to see
every copy.
An agent disk's source ID carries its host, because the registry keeps one
resource per source ID: when it was the bare serial, same-serial agent disks on
different hosts shared one `SourceAgent` mapping, every disk but the last
ingested lost its agent source target, and a disk only the agent reports lost
its metrics target and chart. `HostSMARTDiskSourceID` and
`HostUnraidDiskSourceID` key a disk with a usable serial or WWN as
`<agent ID>/physical-disk:<serial or WWN>`, which the SMART and Unraid
observations of one disk share when both report its serial; a disk without
hardware identity keeps its host/device/topology key. A registry seeded from
unified resources rebuilds one key from the agent ID the host above the disk
retains, directly or above its Unraid array or cache pool, and the merged
disk's serial or WWN (`seedAgentPhysicalDiskSourceIDLocked`), so a rehydrated
disk, which is what the resource API serves, keeps an agent source target,
though not every key its observations ingest under (a SMART row missing the
serial its Unraid row reports, or a host several agent IDs report from); with
no agent host found, a disk with hardware identity seeds its bare serial or
WWN. Canonical IDs do not move: a disk with hardware identity is keyed by it,
and `SourceSpecificID` hashes an agent disk's bare serial or WWN without the
host (`sourceSpecificIDKey`), so a disk split off onto its source-specific ID,
and the exclusion that split it, keep the IDs recorded before the host joined
the source ID. Every host's split copy shares that ID, so a split observation
takes it keyed to its machine when another machine's disk holds it, and joins
the holder on its own machine, whether the split reaches it through
`physicalDiskIDForMachineLocked` or `findMatch`. A split therefore cannot
separate observations of one disk that one machine reports under several agent
IDs: they meet on that ID, as they met on one source key before. Disk history stays keyed by
hardware identity, not host: `HostSMARTDiskMetricID`, like the Proxmox and
TrueNAS writers and `PhysicalDiskMetaMetricID`, prefers the reported serial or
WWN, so a drive keeps one SMART and I/O history across a move between hosts and
across sources that report it by the same identifier, and both hosts of a
dual-ported SAS disk chart the one drive. A host-scoped history key would
orphan the stored history of every agent disk with hardware identity for the
rare collision. Residual: distinct drives that share a usable serial on
different hosts (cloned VM disks with an explicit serial, fixed-serial USB
bridges) interleave their samples in one series, as they already merge into one
disk on a single host. Proof:
`TestSameSerialAgentDisksOnTwoHostsEachKeepTheirMetricsTarget`,
`TestHostSMARTDiskSourceIDScopesHardwareIdentityToItsHost`,
`TestRehydratedAgentDisksKeepTheirLiveSourceIDs`,
`TestSplitAgentDisksOnTwoMachinesNeverOverwriteEachOther`.
A SMART row that reports no serial, smartctl's standby row among them, takes
the serial the host's Unraid inventory reports for the same disk
(`hostSMARTDiskSerial` over `matchUnraidDisk`). The adapter shows that serial
and the disk's metrics target reads it, so `HostSMARTDiskMetricID` resolves the
same serial, and the disk I/O writer keys a device with no SMART reading by its
Unraid serial (`HostUnraidDeviceMetricID`) before its Proxmox and host/device
fallbacks. The writers used to key such a row on its WWN or host/device key,
which the chart did not read, so the disk's SMART temperature and I/O history
never appeared. The row's source ID stays its own (`HostSMARTDiskSourceID`
reads only the row), so its SMART and Unraid observations still ingest under
two keys onto one disk. Samples already written under the old key stay there:
the chart did not read that key while the Unraid row named the disk (only
Unraid OS reports an Unraid inventory, and it is never a Proxmox node whose
disk row could replace the serial), and a device-keyed series can hold another
disk's samples once device letters move. The key follows the inventory: while the host
reports no Unraid row for the disk, the row's own key is both written and read.
A row matches its Unraid row by device path alone, so a row on a path several
of the host's SMART rows share, controller members behind one block device,
takes no serial from it, and the I/O writer still files that device's counter
under no member. The I/O writer's inventory fallback also skips a device
several Unraid rows name, while a SMART row keeps the adapter's first match.
A row's own serial is kept even when it is a placeholder, as the
adapter keeps it, and the Unraid row then stays a disk of its own. Proof:
`TestHostSMARTDiskMetricIDTakesTheSerialItsUnraidRowReports`,
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`.
The adapters' disk temperature rule is shared with the agent disk history
writer. `HostSMARTDiskTemperature` returns the temperature and collection state
a SMART row's disk shows: the row's own reading, or, when it has none, its
Unraid row's under `unraidDiskTemperatureStatus`. `matchUnraidDisk` finds that
Unraid row by the SMART row's usable serial anywhere in the inventory before
any device path, and a placeholder serial matches nothing. The fallback, like
the serial a row without one takes (`hostSMARTDiskSerial`), comes only from an
Unraid row that describes the row's disk (`unraidDiskDescribesSMARTRow`): one
carrying the row's usable serial, or one naming the row's device path when the
row is that whole kernel block device. A controller member, even the only one
reported, and a row sharing its path with other SMART rows are not, because
the Unraid row describes the block device as a whole. `HostUnraidDiskTemperature`
returns what a disk built from an Unraid row alone shows, and
`HostUnraidDiskMetricID` returns the key that disk's metrics target reads: its
usable serial, else its `HostUnraidDiskSourceID`, and nothing for a row without
a device, which is not ingested. The writer used to take only the SMART row's
own reading, so a temperature shown from the Unraid inventory, for a disk whose
SMART probe returned none or one excluded from SMART collection, was never
charted. Proof: `TestAgentDiskChartsTheUnraidTemperatureItShows` and
`TestHostUnraidDiskMetricIDMatchesItsDiskMetricsTarget`.
`HostDiskTemperatureReadings` lists the reading each disk of a host agent shows
for consumers that judge disk heat, the agent disk temperature alerts in
`CheckHost`: one per SMART row the registry ingests (no virtual block device),
then one per Unraid row whose disk key (`HostUnraidDiskMetricID`) no SMART row's
`HostSMARTDiskMetricID` equals, the history writer's rule, with a row without a
device skipped as the registry skips it. Rows with one key are one registry
disk whatever device labels they carry, such as a controller member
(`0 [megaraid,0]`) and its Unraid device (`sda`). When smartctl and Unraid
report different usable serials for one device the registry shows both disks,
and both are listed. Each reading is taken from the disk resource
`resourceFromHostSMARTDisk` or `resourceFromHostUnraidPhysicalDisk` builds, so
its temperature, collection state and disk type are the ones that row's disk
shows. Unraid rows on different devices that share a key and no SMART row are
one registry disk but keep a reading each, since alerts are keyed by device.
Proof: `TestHostDiskTemperatureReadingsMatchTheDisksTheRegistryShows` compares
each reading with the ingested disk, including two disks on one device and a
controller member Unraid lists under its block device.
That same canonical physical-disk view must also expose source-independent host
context. When a disk is API-backed rather than node-backed, typed views should
fall back to canonical host identity such as `identity.hostnames` instead of
@@ -5431,8 +5611,11 @@ should extend these unified-resource owners instead of rebuilding status or
badge logic inside PMG, recovery, dashboard, or infrastructure-local views.
The shared resource-runtime adapter boundary is also owned here now.
`frontend-modern/src/utils/agentResources.ts` owns canonical actionable
resource identities, agent-facet detection, cluster-name fallbacks, and
resource-derived chart-key candidates. `frontend-modern/src/utils/resourcePlatformData.ts`
resource identities, agent-facet detection, and cluster-name fallbacks. It no
longer carries a resource-wide chart-key candidate list: workload table
sparklines match chart series to rows through the chart-key candidates in
`frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts`.
`frontend-modern/src/utils/resourcePlatformData.ts`
owns the typed extraction of platform-data fragments from unified resources,
and `frontend-modern/src/utils/resourceStateAdapters.ts` owns canonical
projection from unified resources into node/PBS/PMG runtime view models.
@@ -5452,12 +5635,14 @@ wording stays aligned with the drawer's recent-change cards and timeline.
Timeline cards in that drawer surface change metadata when it is present, so
the history view preserves the richer provenance already carried by the
unified-resource model instead of flattening those fields away.
The same Infrastructure resource-only links now also default through the
shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
The shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
cards from the Patrol page, resource drawer, and problem-resource dashboard
panels, so canonical resource-filter path construction stays owned by the
shared summary cards rather than being duplicated per surface.
cards keep resource links behind an optional `buildResourceHref` input with no
default, following the 2026-05-16 cross-resource drilldown retirement above.
The resource drawer passes none, so its change and correlation labels render
as plain text; a surface that needs resource links must pass a platform-route
builder into the shared card rather than rebuilding resource-filter paths per
surface.
Platform tables supply the resource-label resolver to the resource drawer
through `PlatformResourceDetailTableRow`. `createPlatformResourceLabelResolver(...)`
in `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
@@ -5948,7 +6133,12 @@ the reference and is never retried. An owned row records its alert's
reference as `alert_resource_id`. A read by the reference returns the rows
journaled under it plus the rows owned away from it, matched through the
indexed alert identifier (`ProxmoxPhysicalDiskAlertIdentifiers`) and that
recorded reference, and every count uses the same predicate. The Alerts
recorded reference, and every count uses the same predicate. Those
identifiers cover the health and wearout specs and the disk temperature
metric spec (`<reference>::metric-threshold:diskTemperature`), since PVE disks
also raise temperature alerts under the same reference
(`TestProxmoxDiskTemperatureAlertRowsFollowRecordedHardwareIdentity` in
`internal/unifiedresources/store_test.go`). The Alerts
history Resource action therefore still lists every occurrence raised under
the path, across the disks that held it, and alert-centric reads keep their
occurrences (see the AI runtime contract's incident-history queries). Rows
@@ -6104,6 +6294,13 @@ presentation. `frontend-modern/src/components/Infrastructure/`
placement, signal, and snapshot context through the canonical resource drawer
and debug/source sections rather than introducing a VMware-only detail route,
drawer tab, or provider-local investigation shell.
The TrueNAS physical-disk drawer judges a current temperature reading by the
disk temperature policy, like the TrueNAS storage table:
`useResourceDetailDrawerDerivedState.ts` passes the alerts store's
`getDiskTemperatureThresholds` into `buildTrueNASDetailSections`, and the
Temperature row takes a warning tone only when `isPhysicalDiskRunningHot`
says the reading reached its disk type's alert trigger. A retained reading
keeps its muted last-known row, and without a resolver heat is not judged.
That same infrastructure consumer boundary also owns source selection
continuity. Settings infrastructure panels and platform/runtime pages must
keep canonical sources such as `truenas` and `availability` present in their
@@ -6186,9 +6383,20 @@ pass and manual links both apply that function:
the best of equal ones. A quiet source drops out of that decision.
- Once every source is quiet, an `offline` verdict survives and any other
verdict reads `warning`. The best of those wins.
- A current facet sighting carries no verdict (the PBS host-agent association,
an availability check projected onto its target). It counts as `online` only
when no current source has a verdict, and reads `warning` once quiet.
- A current facet sighting carries no verdict (the PBS host-agent association).
It counts as `online` only when no current source has a verdict, and reads
`warning` once quiet.
- Availability checks projected onto a monitored resource rank like that facet,
but their shared sighting is not their verdict. `availabilityChecksProveOnline`
judges each check at the pass's `now` by its own evidence window
(`Evidence.ValidUntil`: two poll intervals for a single local check, the
probe report window for a remote or multi-location one). An enabled,
passing check with current evidence proves the resource answers and reads
`online` once one of the resource's own sources has gone quiet and none that
is current has a verdict. Every other check abstains, current or quiet: a
failing check proves only that one port or service does not answer, and a
pass whose evidence lapsed proves nothing now. A check's own row keeps the ordinary
rules.
The stale pass used to read delivery as the verdict. A Proxmox node the cluster
reported offline on a live poll came back `online` once its linked agent fell
@@ -6212,24 +6420,52 @@ online stays `online` when its linked agent stops reporting. A live agent keeps
a node `online` when the Proxmox poll reports it offline, which the Proxmox
nodes table reads with `connectionHealth: error` as a stale provider. When a
guest's or container's only source goes quiet, a running one stays `warning`
and a stopped one stays `offline`.
and a stopped one stays `offline`. In each quiet case a projected availability
check with current evidence lifts the resource to `online`, as described
below.
The sighting still reads `stale`, so health keeps its `telemetry_stale` reason
and monitored-system reasons are unchanged. In-memory clones keep the verdicts,
including a facet's missing one. A serialized copy carries none, so
`IngestResources` gives each sighting of a copy that lost them all the stored
resource status; a merged row's separate source verdicts do not survive that
round trip. A manual link that joins two resources reported by one source keeps
round trip, and a copy whose status a passing check supplied gives that status
to every source. A manual link that joins two resources reported by one source keeps
the fresher sighting. `registry_merge_policy_test.go` pins the node,
poller-expired, guest, round-trip, copy, agent, Docker, Kubernetes,
mixed-source and manual-link cases, and the aggregation table.
Availability checks still count by delivery. With every source that has a
verdict quiet, a current check keeps its target `online` even when the check
fails, as it did before. Several checks share one sighting whose freshness
comes from whichever check was projected last, so an availability verdict
needs each check's own freshness, including local checks that miss their
cadence, and has to survive manual links. That is left to a follow-up.
Availability checks used to count by delivery too. With every source that
has a verdict quiet, a current failing check kept a node `preserveOrExpireNodes`
expired `online`, and a quiet one lifted it to `warning`. Several checks share
one sighting whose freshness comes from whichever check was projected last, so
a failing check that just ran could also vouch for another check's old pass.
Each check is now judged by its own evidence. A failing probe no longer holds
an expired node `online`, and it never makes a guest read stopped to the
consumers that count `offline` as stopped (the workloads summary, the Proxmox
page counts). The failure stays on the check's own row, which raises an
outage incident once the check confirms one (past its failure threshold, from
every location). A check observed from several locations relies on the poller
ageing out a location that stopped running and lasting an available check's
evidence only as long as its newest reachable path (monitoring.md), so one
location's fresh failure cannot lend its freshness to another location's old
pass.
The verdict is read from the checks on the resource when status is
aggregated. Projecting, re-running or retargeting a check also re-applies the
stale pass's rule to every target it touches whose status that pass owns,
because a check is not a delivery from the target's own sources and the
resources API replays checks after its stale pass. The pass owns a status only
through the target's own sightings: the checks' sighting going quiet neither
hands it the target nor, once removed, takes the target away, so a target
whose own sources have not gone quiet keeps the status they gave it. A check
retargeted away therefore takes its verdict with it at once. A
manual link keeps only the primary's own checks, so the merged resource is
judged by the checks it shows and never by the linked resource's fresher
sighting. `registry_test.go` pins the expired-node and quiet-guest cases, both
projection orders, re-judging on check changes, ownership through the
target's own sightings and the manual link; the aggregation table pins the
check rules.
### Canonical object drawer hierarchy
@@ -6368,6 +6604,75 @@ full heading. This shared drawer rule applies across resource types. The 390px
browser qualification checks heading fit and keyboard expansion; no data model,
authorisation, action dispatch or resource admission contract changes.
### Operator links reach record-ingested resources
Every registry ingest path applies the operator's manual links: snapshot
ingest, resource ingest and record ingest (`IngestRecords`,
`IngestRecordsWithStaleThresholds` and the read-state overlay's record pass).
Either side of a link can arrive only as a supplemental record, such as a
vSphere or TrueNAS VM with a Pulse agent inside, or an agent on a TrueNAS host,
and the monitor's rebuild ingests those records after the snapshot. While only
snapshot and resource ingest applied links, the rebuilt registry behind the
websocket broadcast and `/api/state` kept both rows. The resources API, which
seeds through resource ingest, showed them merged, and so did the published
inventory whenever an unrelated absent machine made continuity hydration
clone the registry. Mock mode builds its unified view from fixtures without
the link store, so links do not reach the mock broadcast; that boundary is
separate.
- The monitor adapter passes its configured stale thresholds to record
ingest, so a link joined there judges each side's metrics the way a
snapshot-time link does. Ordinary source merges into an existing row use
the same thresholds on every ingest path: snapshot and record ingest hold
the caller's thresholds while they run, and `mergeInto` judges each
metric's freshness with them through `sourceStaleThreshold`, the rule the
stale pass reads. Before this, those merges used the defaults, so a
Proxmox node polled every two minutes showed a silent auto-linked agent's
readings while the stale pass called the node's poll current, and a
supplemental refresh of a linked vSphere VM kept the agent's memory over
vSphere's current reading. Outside mock mode the monitor configures only
Proxmox, PBS and PMG; mock mode also configures TrueNAS, vSphere and
availability. Regression coverage:
`TestMonitorAdapterSourceMergesUseConfiguredStaleThresholds` (rebuild,
supplemental refresh, overlay, node with auto-linked agent),
`TestSourceMergesJudgeFreshnessByTheIngestStaleThresholds` and
`TestMetricMergeFreshnessMatchesTheStalePass`.
- Continuity records are the one record ingest that never joins links. A
saved enrollment only fills an absent machine, so it stays its own row
instead of lending its linked guest an offline verdict and an old agent
payload.
- A link merge keeps the TrueNAS and vSphere payloads the primary lacks, so
an agent chosen as primary over a TrueNAS system still carries the
system's facet, and the system's pools re-parent to the merged row.
- The registry remembers each resource a manual link folded into its
primary. A folded resource is still observed, so neither pin-driven nor
record-declared succession may succeed its ID. An agent-type primary
carries the folded resource's machine keys and is pinned with them, and
succeeding the folded ID re-keyed the link onto the primary itself, which
split the pair on the next rebuild. That already broke snapshot links whose
primary is a machine-keyless agent-type row, such as a Proxmox node.
- Fold history is registry-local: resource seeding and overlay clones do not
carry it. A reference to the folded resource's canonical ID, such as an
availability check configured against an agent later linked into its
guest, does not resolve to the primary, so the check loses its target in
the broadcast as it already did in the resources API.
`registry_merge_policy_test.go` pins the record links in both directions
through two rebuilds and the live supplemental refresh, and checks that a
registry seeded from the monitor's listing through resource ingest, as the
resources API seeds, lists the same rows
(`TestManualLinksJoinRecordIngestedResourcesInMonitorRebuild`). The same file
pins the node case (`TestManualLinkKeepsItsEndpointsThroughIdentityPinPersistence`),
record-declared succession (`TestRecordDeclaredSuccessionSparesALinkFoldedResource`)
and both payloads (`TestManualLinkKeepsProviderPayloadsThePrimaryLacks`).
`monitor_adapter_read_state_test.go`
(`TestMonitorAdapterJoinsLinkedRecordsWithConfiguredStaleThresholds`) pins the
thresholds on the rebuild, supplemental and overlay paths, and
`internal/monitoring/issue1913_host_continuity_test.go`
(`TestManualLinkToSupplementalGuestHoldsWithAndWithoutContinuity`) pins the
published inventory with no continuity, an unrelated absent machine and the
linked agent's own saved enrollment.
### Provider link network corroboration
An existing reciprocal host/provider link remains authoritative, including a
@@ -6436,9 +6741,52 @@ conflict): the agent's row may be the slot's previous occupant, retained by a
silent agent, and the join would hand the replacement that disk's serial,
readings and canonical resource. A hardware identity match still joins on its
own, and without a WWN on either side the path fallback stands. The seeded
source mapping reused after a restart is a separate path this does not cover.
`TestRegistrySASPathJoinRefusesContradictingWWN` covers the stale row and both
same-disk shapes; monitoring's SMART merge applies the matching guard first.
source mapping a rehydrated registry reuses applies the same refusal (next
section). `TestRegistrySASPathJoinRefusesContradictingWWN` covers the stale
row and both same-disk shapes; monitoring's SMART merge applies the matching
guard first.
### Seeded slot mapping refuses a replaced disk
`ingestRecord` honours a source key's existing mapping, such as one
`IngestResources` seeds from persisted unified resources, before any
identity matching. Proxmox keys a disk by its slot
(`ProxmoxPhysicalDiskSourceID`), so a disk swapped into the slot arrived
under the previous disk's key, and `mergeInto` kept that disk's canonical ID
and, where an agent report had been merged in, its serial, WWN, temperature
and failed health. A Proxmox disk observation now refuses the mapping when
the two carry hardware identities naming different disks
(`proxmoxDiskSlotHoldsAnotherDisk`: `HardwareIdentityConflict` after
`HardwareIdentityMatch`). Only fields reported on both sides count, so a
missing serial or WWN, including Proxmox's literal `unknown`, is no evidence.
Differing serials are no evidence when either side is SAS, because Proxmox
may report the SAS address there (#1595), or when the mapped disk was merged
with an agent report, whose serial `mergeInto` may have kept (collection
status does not reliably say which serial it holds), unless Proxmox read the
drive's own serial too, by the rule monitoring's
`hostAgentSMARTSerialComparable` applies: NVMe on both sides, or an agent
SATA disk under Proxmox vendor `ATA` that Proxmox does not type `usb`, not a
SCSI designator or a USB bridge's serial. A refused key falls through to the linked-disk join,
`findMatch` and `chooseNewID` like an unmapped one, except that none of them
may merge it into the refused disk: an ID that lands on it is replaced with
the key's source-specific ID. Agreement still wins, as it does in identity
matching, so drives that share a serial they do not own, such as a USB
bridge's, read as one disk: a second swap in the same enclosure joins the
first replacement, which reported the same bridge serial. That belongs to
identity matching, not this guard. The refused disk keeps its Proxmox
facet and is not updated, so until the registry is rebuilt both disks answer
to the slot's PVE alert reference, and canonical reference resolution treats
that reference as ambiguous rather than handing it to either. A later
rehydration may seed the slot key to the old disk again, and the refusal
repeats. No current caller ingests Proxmox disk records over a seeded
mapping: the monitor rebuilds a fresh registry each poll, and the resource
API, host-continuity and Patrol registries seeded from unified resources do
not replay Proxmox disk records. This closes the registry contract, not a
reported symptom. `TestRegistrySeededSlotMappingRefusesReplacedDisk` covers
SATA (with and without a new WWN), SAS, USB-enclosure and Proxmox-only
replacements, plus eight same-disk controls (including a SCSI designator
from an agent without collection status and a USB bridge serial under
Proxmox vendor `ATA`), each through two rehydrations.
### Drawer tab selection survives a transient snapshot change (#1723)