Merge branch 'main' into fix/active-alert-store-replace

This commit is contained in:
pulse-triage[bot]
2026-10-07 02:16:55 +01:00
committed by GitHub
122 changed files with 4834 additions and 1074 deletions
@@ -760,6 +760,12 @@ offline the unified-resources adapter reports them `unavailable` with the same
reason (`models.HostAgentStoppedReportingReason`) and leaves them out of the
disk's risk, and the metrics-history API does not return any such retained
disk temperature as a live point.
Downstream readers keep that distinction too. The disk temperature charts do
not pad a series to now with the retained value. The AI chat context, Patrol and
the AI disk tools present it only as a last-known value with its reason. The
performance report and the reporting runtime snapshot leave it out of their
disk tables. The performance report colours a collected reading by the
tenant's alert disk temperature thresholds for the disk type, not a fixed line.
An enabled availability target assigned to a host agent creates an
agent-lifecycle lease for that exact target/agent pairing. First assignment
@@ -288,6 +288,41 @@ exposing the source-specific ambiguous `wearout` name, and distinguishes it from
`smart.percentageUsed` as consumed endurance. The regression proof is
`internal/ai/tools/physical_disk_evidence_test.go`.
Disk temperatures reach the model as current readings only when they were
collected now. `tools.SplitDiskTemperature` classifies a physical disk
temperature with `diskinventory.TemperatureCollected`. The physical-disk
projection's `temperature` and the `disk_health` SMART row's `temperature`
carry only a reading the current observation collected. A value normalization
retained instead (a disk in standby, a host agent past its reporting lease)
moves to `last_known_temperature` with `last_known_temperature_reason`. The
same split feeds the AI chat context and Patrol. The "Physical Disks Needing
Attention" list and Patrol's disk issue gate, triage flags and finding
verification judge heat on the collected reading alone. Their text renders a
retained value as "last known 41C (disk is in standby)". Proofs:
`TestDiskToolsReportRetainedTemperatureAsLastKnown` in
`internal/ai/tools/physical_disk_evidence_test.go`,
`TestBuildUnifiedResourceContextJudgesDiskHeatOnCollectedTemperature` in
`internal/ai/resource_context_test.go` and
`TestPatrolPhysicalDiskRowsJudgeHeatOnCollectedTemperature` in
`internal/ai/patrol_ai_more_test.go`.
They judge that collected reading against the alert disk temperature policy,
not a fixed line. `ThresholdProvider.GetDiskTemperatureThreshold(diskType)`
(the alert manager's per-type trigger and clear) resolves each disk's limits
once in `patrolPhysicalDiskRows`, and the run state carries the provider
(`patrolRuntimeState.thresholdProvider`). With no provider the factory alert
configuration applies. A disk is hot from its trigger: a triage warning, a disk
issue and an AI chat "needing attention" entry, as its temperature alert and
Physical Disks Running Hot verdict are. A disk-high finding recovers at or
below the clear value (under the trigger when there is no band below it), as
the alert does. With no current reading, a disk last seen hot leaves that
verification unknown rather than recovered. Scoped runs keep the provider, and a
provider set before Patrol starts is handed to it. Below the trigger Patrol flags nothing, so an NVMe at 63C stays
quiet and a SATA disk at 56C is flagged. Proofs:
`internal/ai/patrol_disk_temperature_test.go` and
`TestAlertThresholdAdapter_DiskTemperatureFollowsAlertPolicy` in
`internal/ai/alert_threshold_adapter_test.go`.
Retained summaries disclose that point and bucket timestamps describe returned
history inside the requested window. Their spacing does not measure collection
uptime or explain missing history. Collector lifecycle and retention settings
@@ -1694,6 +1694,35 @@ an alert the next evaluation raises again.
`TestConfigSaveKeepsAlertTheEvaluatorKeepsWithoutRecoveryBand` in
`internal/alerts/host_unraid_lifecycle_test.go` pin these paths.
### The disk temperature policy decides disk heat
The policy `CheckHost` applies to a SMART disk (the agent Disk Temp default,
refined by the disk type's `DiskTempByType` entry, and switched off for every
type when that default is off) is the canonical answer to "is this disk too
hot". `diskTemperatureThresholdForType` in `internal/alerts/host.go` holds it,
and `Manager.DiskTemperatureThreshold(diskType)` exposes it, without host
overrides, to Patrol through `AlertThresholdAdapter.GetDiskTemperatureThreshold`.
`DefaultDiskTemperatureThreshold` serves callers with no manager. Disk risk
(`internal/storagehealth`) judges no temperature. The Physical Disks Temp cell
and Health verdict mirror the per-type triggers and a switched-off default in
`resolveDiskTemperatureDisplayThresholds`; host overrides do not reach that
page yet. That resolver returns null when the agent Disk Temp default is off.
The displays that read it treat null as off: no threshold colour
(`getTemperatureTextClass` with `diskTemperature`) and never hot. A
`DiskTempByType` entry cannot be switched off on its own, because
normalization restores a non-positive entry to its default.
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.
`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.
### Agent disk temperature alerts clear when their disk leaves the report
`CheckHost` also clears a disk temperature alert, and drops its pending
@@ -5378,6 +5378,15 @@ Backend API payloads and `frontend-modern/src/types/api.ts` must preserve that
optional map without making it a required compatibility field, and clients must
keep it as descriptive host telemetry rather than a temperature metric,
resource identity, alert metric, or storage/recovery signal.
Host sensor SMART rows (`smart[]`) also carry the optional per-field
`collection` status (`PhysicalDiskCollectionStatus`). Backend API payloads and
`frontend-modern/src/types/api.ts` (`HostDiskSMART.collection`) must preserve it
on the agent facet. When a host agent passes its reporting lease, a SMART
temperature that was current (`available`, or present without provenance) keeps
its value and becomes `unavailable`. A state that was already not available is
kept. Clients must read that state through the shared
`isPhysicalDiskTemperatureCurrent` decision before treating the value as a
current reading.
Agent resource-context sections expose host package posture only as bounded
operational facts: package manager, pending count, inventory state, inspection
freshness, and reboot-required state. Raw package identifiers, versions, and
@@ -6332,6 +6341,23 @@ stored one out to `now`, only from a temperature collected now; without one the
stored samples are returned as they are. An empty mock range can still fall
through to the monitor's generic demo chart series for that disk ID, which is
not derived from the disk's reading.
Reports follow the same rule. A performance report's disk table
(`enrichNodeReport` in `internal/api/metrics_reporting_handlers.go`) and the
reporting runtime snapshot's disks (`internal/api/reporting_runtime_snapshot.go`)
take a disk temperature through `reportDiskTemperature`. It keeps a reading only
when `diskinventory.TemperatureCollected` holds, so a retained value is reported
as 0 (no reading) rather than tabulated as measured. Proof:
`TestContract_ReportsOmitRetainedDiskTemperatures` in
`internal/api/contract_test.go`.
Each reported disk also carries its alert disk temperature thresholds
(`reporting.DiskInfo.TemperatureWarning` / `TemperatureCritical`, the clear
value and the trigger). `reportDiskTemperatureThresholds` resolves them from
the tenant's alert manager (`alerts.Manager.DiskTemperatureThreshold`, the
factory policy when there is none). The PDF disk table colours a reading amber
from the clear value and red from the trigger, as the Physical Disks Temp
column does, and leaves it plain when disk temperature alerting is off. It no
longer uses a fixed 50/60C. Proof:
`TestContract_ReportsCarryDiskTemperatureAlertThresholds`.
That same metrics-history contract also owns Kubernetes pod identity
normalization. `/api/metrics-store/history` must accept legacy bare pod IDs
such as `cluster-1:pod:pod-1`, canonicalize them onto the unified pod metrics
@@ -412,6 +412,12 @@ disk header and tooltips. Structured ZFS scan activity supplies a compact badge
only during reported rebuild activity; its complete provider summary remains
available. Stable disk History catalog/organisation/access ownership is unchanged.
The physical-disk verdict words and their phone forms are `Needs Attention`
(`Attention`), `Running Hot` (`Hot`) and `Replace Now` (`Replace`), all from
`getPhysicalDiskHealthCompactLabel` in `diskPresentation.ts`. `Running Hot` is
red and starts at the disk's alert trigger, from the same thresholds object
that colours its Temp cell (`getPhysicalDiskTemperatureThresholds`).
Controller expansions carry the supplied kind-specific targets, absolute times,
duration and cluster/namespace when the phone row omits those columns. Proxmox
coverage distinguishes independent backups from guest-local snapshots; By date
@@ -688,11 +694,13 @@ same relative point in the selected history range. Leaving the row clears the
cursor and restores the bars and I/O readouts together. The lens mounts with a short
reduced-motion-safe fade and must not leave both bar and chart semantics in the
accessibility tree simultaneously.
Bar mode resolves history only for that active guest through its canonical
metrics target and the selected compact range; it must not start an
estate-wide chart request merely because the range changes. The active request
key is stable across equivalent live guest snapshots, and leaving the row or
selecting another range aborts superseded browser work. Persistent Trends may
Bar mode resolves history per guest through each guest's canonical metrics
target and the selected compact range, warming only a bounded window of
mounted and adjacent rows with the active guest first; it must not start an
estate-wide chart request merely because the range changes. The request key is
stable across equivalent live guest snapshots, and selecting another range
aborts superseded browser work. Leaving a row does not cancel its read, which
settles into the bounded row cache. Persistent Trends may
retain the shared estate reader, but range changes must clear prior-range data
unless an exact-key cache entry exists.
@@ -2911,9 +2919,9 @@ Agent`), with the plain-language source phrase available through accessible
`pveVersion` or a Pulse Agent report whose OS identity resolves to Unraid or
Proxmox VE. They must omit the version rather than showing unrelated
collector OS versions, such as Debian 12, beside an API-backed PVE badge.
Shared row primitives that render Proxmox node identity, including
`frontend-modern/src/components/shared/NodeGroupHeader.tsx`, must route raw
PVE manager payloads through
Surfaces that render Proxmox node versions, including the Proxmox page
model at `frontend-modern/src/features/proxmox/proxmoxPageModel.ts`, must
route raw PVE manager payloads through
`frontend-modern/src/utils/proxmoxVersion.ts` rather than inlining
page-local parsing or falling back to unrelated agent OS versions.
System title metadata must apply the same identity rule: once the primary
@@ -4395,12 +4403,14 @@ The shared table chrome now allows `TableCardHeader` to expose a right-aligned
action slot, currently used by the Workloads/Proxmox metric display control.
That slot belongs to the table header band and must not reintroduce nested
cards or page-local toolbar wrappers inside `TableCard`. Proxmox host grouping
also extends the shared `NodeGroupHeader` row pattern: host metrics may align
with workload table columns, but the shared primitive owns the header/table
shell boundary rather than platform pages copying their own card headers.
Compact PVE version text in that header must come from the shared Proxmox
version formatter so raw `pve-manager/...` payloads and platform-page host
version cells stay consistent.
also extends the shared `NodeGroupHeader` row pattern, which owns the
header/table shell boundary rather than platform pages copying their own card
headers. `frontend-modern/src/components/shared/NodeGroupHeader.tsx` renders
node identity only (status dot, linked name, cluster and agent badges); in
table-row mode that identity sits in one cell spanning the table. It carries no
per-column cell renderer and no inline
version, temperature, or uptime facts: host metrics and versions belong to the
platform page's host table, so a group row must not duplicate them.
Mobile navigation now recognizes `proxmox` as a first-class platform tab in
the shared priority model so app-shell ordering remains centralized.
@@ -5591,6 +5601,14 @@ Standalone, TrueNAS, and vSphere platform tables and their table-model helpers
must compose those helpers instead of declaring local `metricFallback` /
`finiteMetric` helpers or inlining centered muted dash fallback markup in
metric cells.
A row whose status indicator is `danger` (offline) blanks every reading from
its last report, not only the metric bars. Proxmox node rows already gate
uptime and temperature on online. The Machines table gates its Uptime and
Temperature cells, and the Docker hosts table its temperature cell, on the same
check. A machine another source keeps up stays rendered, since its cell may
carry that source's current reading. Numeric cells keep their own
right-aligned empty dash; the centred `PlatformTableMetricFallback` marker
stays specific to metric-bar cells.
Platform table metric severity coloring is alert-backed, not hardcoded. The
Docker host and container, Proxmox node, Kubernetes cluster and node, TrueNAS
system and app, and vSphere host tables must resolve display thresholds
@@ -7803,6 +7821,16 @@ never through the threshold colour classes, so a retained reading cannot look
hot or healthy. Table, drawer and pool surfaces share that one decision in
`frontend-modern/src/features/storageBackups/diskPresentation.ts` rather than
each re-reading `collection.temperature`.
That decision and its class now live in the small
`frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts`
module, which `diskPresentation.ts` re-exports, so the Machines table and the
machine drawer's Thermals rows apply the same treatment without pulling the
Storage presenter into their chunks. Those surfaces and the guest drawer's
Physical Disks card import it directly.
When the cell's value is a retained host-agent SMART temperature, it renders
muted with a dotted underline and screen-reader "last known", as does a
retained value in the guest card. Tooltip and Thermals rows say "(last known)",
and the Thermals row carries the collection reason as its title.
The focused browser proofs are
`frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts`,
@@ -7981,6 +8009,15 @@ Rendered table proof belongs in
drawer grouping and fallback proof belongs in
`frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts`.
### The Proxmox nodes table reads node history only
`frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx` draws its Trends
sparklines through `useWorkloadTableMetricHistory` with `series: 'nodes'`, so
it polls the infrastructure summary and no guest history. The embedded
workloads table below it owns guest history and passes `series: 'guests'`.
`ProxmoxNodesTable.test.tsx` pins the option; the per-page polling budget
belongs to performance-and-scalability.
### Proxmox Storage reuses the shared product-family source scope
`frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx` mounts the
@@ -1679,6 +1679,62 @@ boundaries:
`TestMockKubernetesLoneHeavyPodStaysUnderNodeCeiling` in
`internal/mock/generator_test.go`.
Mock alert evaluation covers physical disks as well. Live Proxmox disks are
evaluated by the physical disk poller in `maybePollPhysicalDisksAsync`, which
only polls configured Proxmox instances, so fixture disks had no evaluation
path. The fixture deliberately keeps a FAILED health cohort and worn SSDs
stable across restarts, so the estate showed failing and "Replace Now" disks
with no `disk-health` or `disk-wearout` alert. `checkMockAlerts` now evaluates
fixture disks through `checkMockPhysicalDiskAlerts`. Both paths share the
per-disk rule in `checkPhysicalDiskAlerts` (a device matched by the linked
agent's `--disk-exclude` patterns is evaluated as healthy, otherwise
`CheckDiskHealth` judges the disk), and the mock pass applies the poller's
boundary around it:
- Only disks on online nodes are evaluated, as the poller only evaluates nodes
it reached. A disk on an offline node keeps whatever alert it had. The mock
pass treats every disk on an online fixture node as collected; the poller
also skips a node whose disk query and SMART fallback both failed. Nodes are
matched by instance and name, as the poller only pairs a disk with its own
instance's nodes.
- Excluding a disk resolves the alerts it already raised, because the excluded
device is evaluated as healthy rather than skipped.
- The poller's wait for host-agent links to settle after a restart (#1674) is
not applied: fixture links and exclusions are complete from the first pass.
- A disk whose node name leaves the estate loses its alerts through
`CleanupAlertsForNodes`, which the same pass already runs. That cleanup
matches node names only, so it relies on fixture node names being unique
across instances, which they are.
The disk loop relies on the pass's single up-front mode check, like every
loop in `checkMockAlerts` except the host-agent step, which re-checks under its
own lock. A pass already past that check when mock mode is switched off can
reopen its fixture alerts after `SetMockMode(false)` clears them, and
other paths that evaluate fixture data (frontend refresh into unified alert
evaluation, backup rollups) have the same window. That mode-exit race predates
the disk loop and is not closed here; a lock around `checkMockAlerts` alone
would leave the other paths open.
The disk evaluation runs on the mock alert tick, not the poller's disk
interval (five minutes by default, `PhysicalDiskPollingMinutes` per instance).
`CheckDiskHealth` logs at error level whenever it evaluates a FAILED disk
(models with a known firmware bug skip the health check) and at warn level for
a worn SSD, so an evaluated fixture disk in either state logs on every tick. On
2026-10-06, private stacks with the public demo's estate (8 nodes) carried 12
Proxmox disks, none FAILED. Baseline against patched, the only new disk alert
was one warning-level `disk-wearout` (a 7% SSD on `pve5`), whose resource ID is
one of the disk resource's canonical aliases, and neither stack had a
`disk-health` alert. `CheckDiskHealth` raises only those two alert types, so
the other differences between the two boots (ZFS and Docker service alerts) are
not from the disk pass; those fixtures draw random state each boot. The default
test estate raises five `disk-health` and one `disk-wearout` alert. The tests
pin the boundary: `TestCheckMockAlertsEvaluatesPhysicalDisks` and
`TestCheckMockPhysicalDiskAlertsFollowsPollerBoundary` in
`internal/monitoring/monitor_mock_alerts_test.go`, with
`TestProxmoxDiskAlertsRunOnMergedDiskState` in
`internal/monitoring/canonical_guardrails_test.go` keeping the poller on the
shared helper.
Host and container-runtime disk collection supports an explicit include list
for filesystems hidden by Pulse's automatic virtual/container filtering. The
include list is bounded to that automatic filter; explicit disk exclusions
@@ -3815,6 +3871,15 @@ consumers outside the history writers: the metrics-history API serves a disk
series from the disk's reading or pads a stored one, only from a collected
temperature, so a retained standby or silent-agent reading never reappears as a
point at the current time.
The SMART history writers in `internal/monitoring/monitor.go` and
`monitor_agents.go` call that helper rather than a private copy of the rule,
and `GetPhysicalDiskTemperatureCharts` pads a short disk temperature series out
to now only with a collected reading: with a retained one the stored samples
stay as they are, and a disk with none gets no series. Mock mode can still
substitute its generic demo series for that disk ID before this step; that
series is not derived from the disk's reading. Proof:
`TestDiskTemperatureChartsPadOnlyWithCollectedReading` in
`internal/monitoring/monitor_metrics_slo_test.go`.
Unified-resource physical-disk round trips must retain named
`StorageGroup` membership rather than degrading it to the generic `Used`
filesystem label.
@@ -4436,17 +4501,18 @@ emit structured storage topology such as Unraid per-disk state, the shared
assessment layer must derive canonical risk and alert severity from that
richer disk topology instead of letting coarser aggregate counters override it
and flap the operator-facing storage alert surface.
That shared assessment judges only a disk temperature that was collected now.
`storagehealth.CollectedTemperature` passes a reading through when its
`collection.temperature` state is `available`, or when the source predates
collection state, and otherwise drops it before `AssessPhysicalDisk` and
`AssessHostSMARTDisk` build their sample. A last known value retained for a
disk in standby or a host agent past its reporting lease therefore raises no
`temperature_high` reason and cannot turn the disk's verdict or status into
a warning, while the value itself stays on the resource as last-known
history. Proof: `TestDiskAssessmentsIgnoreRetainedTemperature` and
`TestCollectedTemperatureKeepsOnlyCurrentReadings` in
`internal/storagehealth/risk_test.go`.
That shared assessment never judges disk temperature. Heat is a metric owned
by the alert disk temperature policy (`alerts.Manager.DiskTemperatureThreshold`:
the per-type `diskTempByType` trigger, else the agent Disk Temp default), which
users tune per disk type. Disk risk is rebuilt by registries that cannot see
that configuration, so a heat rule here would contradict it: a flat 60/70C rule
once called a healthy 63C NVMe hot and a 56C SATA disk fine. No disk type,
temperature or collection state reaches `storagehealth.Sample`, the TrueNAS
provider and the Unraid inventory assessment included, and a hot disk keeps an
`online` status. Proof: `TestDiskAssessmentsNeverJudgeTemperature` in
`internal/storagehealth/risk_test.go` and
`TestRecordsLeaveDiskHeatToTheAlertPolicy` in
`internal/truenas/provider_test.go`.
That same monitoring-owned storage polling boundary also owns cluster-shared
Proxmox storage status coherence. `internal/monitoring/monitor_polling_storage.go`
must merge shared storage observations across nodes into one cluster-scoped
@@ -1195,6 +1195,11 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
Saves run one metadata write per changed link, then dispatch the
metadata-changed events together so whole-snapshot listeners coalesce
onto one in-flight refetch instead of refetching per link.
The drawer overview lazy-loads `GuestPhysicalDisks.tsx` under a local
`Suspense`, so the physical-disk presentation module
(`diskPresentation.ts`) loads with an open agent guest's drawer instead
of joining the WorkloadsSurface chunk.
`WorkloadsSurface.performance.contract.test.tsx` pins the dynamic import.
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
A current metric may remain visible in a chart legend only when labelled
`current`; it must never be expanded into synthetic timestamps or a flat
@@ -1933,9 +1938,17 @@ internal persistent signal so the toggle and the hosts-table render
stay in lockstep. The history range remains inline in both display modes because
the default bars mode uses it for the intent-driven guest-row history lens.
Embedded sibling tables that opt into sparklines
re-instantiate `useWorkloadTableMetricHistory`; the cache key matches
the workloads-table reader so both readers dedupe their fetches and
the canonical Workloads hot-path budget is preserved. Standalone
re-instantiate `useWorkloadTableMetricHistory` and name the rows they draw
through its required `series` option. The Proxmox nodes table passes
`'nodes'` and polls only the infrastructure summary; the WorkloadsSurface
reader passes `'guests'` and polls only the workloads history. Two readers
on one page do not share reads: each forwards its own abort signal, which
bypasses the summary caches' in-flight dedupe, and retained query values
seed matching mounts and source changes without deduplicating requests. A
reader therefore must not poll a summary its rows do not render. In Trends
the vSphere overview polls only the workloads history, and the Proxmox
overview polls the workloads history and the infrastructure summary once
each. Standalone
WorkloadsSurface callers (no override props) keep the original
persistent-signal-backed behavior.
Every toolbar that controls a `WorkloadsSurface` consumes
@@ -1961,22 +1974,20 @@ query persistence once per resource, or run on the steady-state alert hot path;
ancestor lookup remains bounded by canonical hierarchy depth for each active
alert actually evaluated.
The embedded WorkloadsSurface exposes a `compactGroupHeaders` prop on
`frontend-modern/src/components/Workloads/useWorkloadsState.ts` that
platform pages owning their own hosts table (Proxmox overview today) set
to strip per-host metric cells out of the `NodeGroupHeader` rows in
grouped mode. The flag is threaded through
`frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`,
`frontend-modern/src/components/Workloads/WorkloadsTable.tsx`, and
`frontend-modern/src/components/Workloads/WorkloadPanel.tsx`; when set,
`WorkloadPanel` calls `NodeGroupHeader` without `columns` /
`renderColumnCell` so the existing colspan layout renders just the
status dot, linked node name, cluster badge, and agent badge — the
duplicate CPU / Memory / Disk / uptime / temperature / version stats
the top-of-page hosts table already owns are dropped. New embedded
platform-page consumers that render their own hosts summary must set
this flag; standalone Workloads surfaces (no top hosts table) keep the
original verbose group rows by default.
Grouped host rows in the embedded WorkloadsSurface are identity-only
dividers. `frontend-modern/src/components/Workloads/WorkloadPanel.tsx`
renders each grouped node through `NodeGroupHeader` as one cell spanning
the table: status dot, linked node name, cluster badge, and agent badge.
Per-host CPU, memory, disk, network and disk I/O, uptime, temperature,
and version stats belong to the platform page's own hosts table (the
Proxmox nodes table today), so the workloads table must not render them
per column or inline in the group row. The former `compactGroupHeaders`
option and the per-column node metric cells it switched off were removed
because both remaining production mounts (Proxmox and vSphere overview)
set it unconditionally, so the non-compact path no longer rendered
anywhere. A new embedded consumer that needs host
stats beside its workloads must render its own hosts table rather than
re-growing metric cells in the shared group row.
WorkloadsSurface stays monitoring-first. It must not render a persistent
aggregate banner just because running VMs or system containers lack an
@@ -2011,14 +2022,20 @@ The Workloads table metric display mode is part of the protected Workloads
hot path. Default bar mode keeps compact current-value bars at rest and swaps
only the active fine-pointer or keyboard-focused guest row to the original
sparklines; persistent Trends mode keeps sparklines visible for every rendered
row. Bar mode does not hydrate estate history at rest: the selected range is
local state until a row is active, then one centralized query reads only that
guest's canonical metrics target from `/api/metrics-store/history`, capped at
36 points. Equivalent live guest snapshots must not restart that request, and
leaving the row disables it; per-row query owners, fan-out, and polling remain
forbidden. Persistent Trends retains the shared, cache-keyed
`fetchWorkloadsSummaryAndCache` / `fetchInfrastructureSummaryAndCache` reader
for all rendered rows. Range-sensitive readers clear prior-range data when no
row. Bar mode does not hydrate estate history at rest. With the history lens
on, one centralized queue warms a bounded window instead: up to the first six
guests of the mounted table window at rest, then the active guest first plus
its next four and previous one, with at most four reads in flight and 18 cached rows. Each read
targets one guest's canonical metrics target on `/api/metrics-store/history`,
is capped at 36 points, and is never polled. Equivalent live guest snapshots
must not restart a read. A range change or leaving the lens mode cancels
pending and in-flight reads, while leaving a row lets its read settle into the
bounded cache. Per-row query owners, estate-wide fan-out, and polling remain
forbidden. Persistent Trends retains one cache-keyed
`fetchWorkloadsSummaryAndCache` reader for all rendered guest rows. Grouped
host rows render no metric cells, so the WorkloadsSurface reader never polls
`fetchInfrastructureSummaryAndCache`; node series belong to the page's hosts
table reader. Range-sensitive readers clear prior-range data when no
exact cache entry exists and forward cancellation so superseded range work
does not continue occupying browser connections. Sparkline ranges must stay
bounded to the governed compact table windows. Expanded history belongs in the existing guest drawer chart
@@ -2057,6 +2074,18 @@ render those sensor values already present on the selected resource payload,
but it must not add host powercap reads, sensor-specific history reads,
per-row polling, browser-side command assumptions, or table-wide aggregation
work.
Host-agent SMART disk rows in `resourceDetailMappers.ts` mark a retained
temperature "(last known)", with its collection reason as the title, from the
payload's own `collection.temperature` state, through `getPhysicalDiskTemperaturePresentation`
in `frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts`.
That module stays separate from `diskPresentation.ts` so the drawer and
Machines chunks take the decision without the Storage presenter, and the rows
add no reads, polling or aggregation work.
A silent host agent's other Thermals rows (pressure, limits, GPU, temperatures,
additional sensors, fans, power) take the same "(last known)" suffix and reason
title. `buildTemperatureRows` gets them through its `lastKnownReason` option,
which the drawer sets from the resource's own `agent.stale`, so the drawer
still reads nothing extra.
The Proxmox node drawer overview should follow the existing guest drawer
compact detail-section pattern and expose node-specific context such as platform,
kernel, hardware, raw capacity, telemetry, and thermal facts rather than
@@ -970,6 +970,11 @@ the page supplies it through the Workloads `tableTitle` slot and the shared
states. This heading alignment changes neither workload backup scope nor the
storage/recovery evidence carried by the adjacent Backup column and Backups
tab.
The guest table's grouped node rows are identity-only dividers. Node stats and
the node drawer stay with the Proxmox nodes table above it, so `ProxmoxPageSurface`
passes `groupNodeDrawerMode: 'disabled'` and no host-metric option to the
embedded Workloads state. Those dividers carry no backup or recovery evidence;
the per-guest Backup column remains the overview's protection signal.
The overview's node-row action selects the shared workload node scope and
reveals the guest section. The title names the selected node and reports the
filtered guest count, including zero. Activating the selected node again or
@@ -3169,6 +3174,16 @@ pointer isolation are
pinned by `TestResourceFromHostPreservesCustomSensorMeta` and
`TestCloneResourceIsolatesCustomSensorValues`.
### Expired reporting leases change status only
`SourceStatus` now carries an unexported marker that a push reporter (host
agent, Docker, Kubernetes) delivered its machine, Docker host or cluster as
offline at lease expiry. Status aggregation reads it, so such a row is
`offline` instead of `warning`. The marker is not serialized, persisted or
exposed on the wire, and the public `status`, `lastSeen` and cadence fields
are unchanged. No storage path, recovery read or protection judgement depends
on it. `TestCloneResource_MutateSourceStatusMap` pins that clones keep it.
### Shared system-settings boundary dropped dead auto-update schedule fields
The shared `internal/api` system-settings surface this subsystem consumes
@@ -3814,6 +3829,21 @@ or alert evaluation. Verification: `diskPresentation.test.ts`,
`diskDetailPresentation.test.ts`, `storagePoolDetailPresentation.test.ts`,
`DiskList.test.tsx` (a row moving between current, retained and legacy states)
and `DiskDetail.test.tsx`.
The same decision reaches the disks that only a host agent's own SMART rows
describe. `HostDiskSMART.collection` now crosses into the frontend type, and the
helpers live in `frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts`
(re-exported by `diskPresentation.ts`, one implementation) so the Machines
table and the machine drawer can use them without loading the Storage
presenter. The guest drawer's card already loads it for extraction and health.
A silent agent's retained SMART temperature never becomes the Machines row's
value while another disk has a current reading. When the machine has no
positive direct or `temperatureCelsius` reading and no non-standby disk is
current, the cell shows the hottest retained non-standby value as last known,
without the per-disk-type threshold colour. The guest drawer's Physical Disks card marks a
retained value the way the Physical Disks table does. Still presentation only.
Verification: `diskPresentation.test.ts` (one implementation behind both import
paths), `agentMachineTableModel.test.ts`, `AgentsMachinesTable.test.tsx`,
`resourceDetailMappers.test.ts` and `GuestPhysicalDisks.test.tsx`.
Direct SATA, SAS, and NVMe device fallback identities remain compatible.
Controller-member fallback identities add controller/target scope only where
@@ -6727,6 +6757,38 @@ disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted
`HistoryChart.test.tsx` regressions verify presentation/input behaviour only,
not live appliance collection or recovery success.
### Physical-disk heat is not a replacement verdict
The Physical Disks Health verdict names the action it asks for. A disk whose
current temperature has reached its alert trigger reads `Running Hot` (compact
`Hot`), red, with the reading and the trigger as its summary. `Replace Now`
stays reserved for FAILED health, any critical risk reason, and a critical
level that no listed reason explains. `Needs Attention` keeps the remaining
warning evidence. Heat outranks warning evidence and failure evidence outranks
heat. Sorting follows the verdict (replace, heat, attention), and the health
filter places a hot disk with the critical disks, as its Temp cell reads red.
Heat has one owner: the alert disk temperature policy (the per-type triggers in
Alerts > Thresholds, `diskTempByType`, else the agent Disk Temp default).
Disk risk never carries heat, because every registry that rebuilds it lacks the
alert configuration. `extractPhysicalDiskPresentationData` takes the alerts
store's `getDiskTemperatureThresholds` resolver, so the Temp cell colour and the
verdict read one thresholds object. The band below the trigger is a Temp cell
colour only, and a retained (not current) reading is never heat. The verdict
judges the current reading, with no memory of an alert. A disk temperature
alert held between its clear value and trigger shows an amber Temp cell, not
`Running Hot`. A switched-off agent Disk Temp default resolves to null, which
leaves the reading uncoloured and never hot, as alerting is off for it.
Per-host Disk Temp overrides do not yet reach Physical Disks. PDF performance
reports colour a disk reading by the same per-type thresholds, which
`reporting.DiskInfo` carries.
`frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts`
pins cell-and-verdict agreement for every type from 40C to 80C under factory and
raised triggers, plus retained readings, failure evidence, sorting and
filtering. `frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx`
covers NVMe 63C, SATA 56C and a raised NVMe trigger loaded through the alerts
store.
### Pool-to-physical-disk ownership in Storage details
`storagePoolDetailPresentation.ts` must scope inferred ZFS device and UnRAID
@@ -6779,3 +6841,6 @@ and the Unraid physical-disk adapter marks an inventory temperature kept past
the host agent's lease as no longer collected. Both change presentation of
values Pulse already holds: no metrics write, retention window, backup,
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.
@@ -1111,6 +1111,17 @@ The first reason renders in the desktop Health column on the row's single line,
coloured by bucket, with a count for the rest and the full list in the title
and screen-reader text. Phones keep the status dot and reach the reason through
the row drawer. Health sorts by severity rank, not label text.
Disk risk carries no heat, so the table judges it itself. A disk whose current
reading has reached its type's alert disk temperature trigger is an Attention
row with the reason "Disk temperature is 57°C, at or above its 55°C alert
threshold.". The trigger comes from the alerts store's
`getDiskTemperatureThresholds`, which the table passes to the row build,
filter, counts, sort and issue. `isPhysicalDiskRunningHot` in
`features/storageBackups/diskTemperaturePresentation.ts` makes the same call
for the Physical Disks verdict. The heat reason follows any native TrueNAS
alert text, and a hot disk's status dot turns warning though its source state
stays `online`, because phones show only the dot.
`truenasPageModel.test.ts` and `TrueNASStorageTopologyTable.test.tsx` pin it.
The vSphere Datastores and Networks tables follow the same exception-first
Health column. `getVmwareDatastoreIssue` and `getVmwareNetworkIssue` return
nothing for a green row; otherwise the reasons are, in order, an impaired
@@ -1535,7 +1546,10 @@ When every node shares one cluster and none is standalone, the model drops
the `cluster` column (it would repeat one value on every row) and
`ProxmoxNodesTable` names that cluster in the table header instead, visible
during search and independent of the inventory-count preference, so the
cluster identity is never lost. Workload status buckets read the canonical
cluster identity is never lost. Its Trends sparklines read node history
through `useWorkloadTableMetricHistory` with `series: 'nodes'`; guest history
stays with the embedded workloads table below it, so the nodes table adds no
guest-history poll to the page. Workload status buckets read the canonical
health too: a running workload whose unified health names an open
`warning_alert` or `critical_alert` reason counts under Attention, while
`backup_stale` and other non-alert reasons do not.
@@ -2269,6 +2283,22 @@ and temperature facts must also compose the frontend-primitives
`InfoCardKeyValueRow`. Mobile rows retain their condensed endpoint layout;
desktop rows use the shared fixed label track so labels and values remain
visually adjacent instead of spanning the full drawer width.
A standalone host agent past its reporting lease reaches the Machines table as
`offline`, as described in "A push reporter past its lease is offline". The
offline metric fallback blanks its CPU, memory, disk, network, disk I/O, uptime
and temperature cells. The machine drawer drops a silent agent's uptime and
marks its non-disk Thermals rows "(last known)" with the reason as title,
keyed on `agent.stale` because those rows are that agent's own sensors. For SMART disk
temperatures on rows that still render, the provenance travels on
`agent.sensors.smart[].collection`: the Machines temperature cell
(`AgentsMachinesTable.tsx`, `agentMachineTableModel.ts`), its tooltip and the
drawer's Thermals rows (`resourceDetailMappers.ts`) read it through
`isPhysicalDiskTemperatureCurrent`, so a retained disk reading never stands for
the machine while another disk has a current one. A positive direct
`temperature` or `temperatureCelsius` reading carries no collection state and
still leads the cell. Without one, and with no current non-standby disk, the
hottest retained non-standby disk reading shows as last known without
threshold colour.
The same boundary applies to availability facts, resource change-history
metadata, Docker/PBS/PMG service facts, nested PMG queue/mail breakdowns, and
Docker container-update management facts. It also covers action-history facts,
@@ -2973,7 +3003,10 @@ canonical WebSocket snapshot, including the public `vmware-vsphere` alias for
the raw `vmware` source, rather than degrading a source-scoped page to periodic
REST refreshes. VMware Overview passes that same source-scoped snapshot into
the embedded Workloads state, so hosts and VMs share one inventory generation
and one explicit refresh path.
and one explicit refresh path. Beyond disabling the grouped host drawer, it
passes no option that adds host metrics to grouped rows: the shared Workloads
group row carries host identity only, and per-host stats stay in the page's
own hosts table.
Its page-owned workload toolbar consumes the complete shared
`getWorkloadsMetricFilterProps` binding, so vSphere VMs expose the same Bars,
Trends, Details, History, range, and first-use discovery contract as every
@@ -3542,8 +3575,8 @@ and otherwise `available`. The value is kept as last-known context; it is
collected again only when a reporting host sends a positive reading for a disk
that is no longer spun down. The Unraid-native disk row carries that state, and
so does a SMART row that falls back to the inventory reading because it has
none of its own. The adapter's risk assessment of an Unraid row no longer counts
a withdrawn inventory temperature as heat.
none of its own. The adapter's risk assessment of an Unraid row judges no
temperature at all; heat belongs to the alert disk temperature policy.
Cross-source correlation compares normalized serial and WWN values across
fields without truncation, allowing a PVE bare-hex array-volume serial to join
@@ -4089,13 +4122,13 @@ incidents during cross-source merges. A provider alert such as TrueNAS
`truenas_smart` is not presentation-only context; it must become a canonical
`physicalDisk.risk.reasons` entry so hybrid agent/API disk resources keep one
shared disk-health truth after deduplication.
That shared risk contract judges only a collected temperature. Both the
adapter assessment and the registry's recompute from merged metadata
(`physicalDiskAssessmentFromMeta`) pass the temperature through
`storagehealth.CollectedTemperature`, so a retained `unavailable`,
`unsupported` or `missing` temperature stays visible as last-known evidence
but adds no `temperature_high` reason and leaves the disk `online` rather
than `warning`. Proof: `TestPhysicalDiskRiskIgnoresRetainedTemperature` in
That shared risk contract never judges temperature, collected or retained.
Heat belongs to the alert disk temperature policy, which no registry can see,
so neither the adapter assessment nor the registry's recompute from merged
metadata (`physicalDiskAssessmentFromMeta`) passes a temperature into
`storagehealth`. A 72C disk keeps its reading on the resource, adds no
`temperature_high` reason and stays `online`. Proof:
`TestPhysicalDiskRiskNeverJudgesTemperature` in
`internal/unifiedresources/registry_test.go`.
That same canonical disk contract now also owns recent aggregate temperature
history. When a provider such as TrueNAS can supply `disk.temperature_agg`
@@ -6039,6 +6072,34 @@ to that boolean. Identity succession continues to rekey the same state row.
round-trip persistence, attention suppression, and the retired remediation
lock.
### A push reporter past its lease is offline
The host agent, Docker and Kubernetes collectors push their own reports, and
the monitor marks the machine, Docker host or cluster offline only once its
reporting lease runs out. Ingest records that verdict on the source's
sighting, and `aggregateStatus` counts such a sighting as `offline` whatever
its age. The stale pass used to rank the stale sighting above `offline`, so a
silent standalone agent reached every consumer as `warning`. The Machines
table then showed it amber with its last report rendered as current readings,
while its own drawer led with a critical "Host is offline" alert. A Docker host
was offline only until its sighting crossed the 120-second stale threshold,
then flipped to `warning`. With this rule, such rows take the existing
offline treatment: frontend danger gates, the health verdict's `offline`
reason, offline filters and counts.
`SourceStatus.Status` keeps describing delivery freshness, so the sighting
still reads `stale`. Health keeps its `telemetry_stale` reason, and
monitored-system reasons are unchanged. A live source still carries a merged
row: a Proxmox node whose linked agent stopped reporting stays `online` through
the PVE poll. Pull sources (Proxmox, PBS, PMG, TrueNAS, vSphere) and the
resources push collectors report about (guests, containers, pods, disks) keep
the stale-to-warning rule. A JSON copy re-derives the unexported marker from
its stored status in `IngestResources`, and a manual link that joins two
resources reported by one source keeps the fresher sighting. The tests in
`registry_merge_policy_test.go` pin the agent, Docker, Kubernetes,
mixed-source, round-trip and manual-link cases, plus the boundaries that stay
unchanged.
### Canonical object drawer hierarchy
The unified resource drawer is the shared operator-facing detail contract for
@@ -0,0 +1,49 @@
{
"version": 1,
"base_sha": "723f3165a1292268634b6054ab93d3015805d990",
"verified_at": "2026-10-06T23:14:41Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "f141220216a968f5953b7646035108f015a9f7de3c5ae5a9e3251ec6e7046fa5",
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts": "07b26eafecbd1ec93fb1dd90d4af6629778cef59a2d9e7450426cb88c90bdfdb",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "70955e370c5b2611557a5de8ba4df44b8f6dce47c1b08c611a154d232c786501",
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "450cce7a4723ce7fe7b01ffe9d10c7d6439c34a1a6dda4fc0af6689ceb4741b5",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "8d63a04f6a475f39de90cb9813b8bd4315525bf63318cfaf958ac195ae07950f"
},
"routes": [
"/standalone/machines",
"/docker"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Two private mock stacks (8-node estate) built from this tree: one with this commit's registry.go/types.go, one with origin/main's, both with a local-only go build -overlay of internal/mock/generator.go that holds standalone Linux agent host-linux-1 (Apollo-114) and Docker host edge-proxy-01 past their reporting leases the way evaluateHostAgents/evaluateDockerAgents leave them: status offline, LastSeen frozen 14m/9m before the first tick, last readings kept. Mock mode skips the host health sweep, hence the overlay.",
"Before (origin/main backend and frontend): /api/resources served Apollo-114 as status warning with agent.stale true and edge-proxy-01 as warning with health stale. The Machines row showed an amber dot, CPU 32%, Memory 74%, Disk 80%, Net and Disk I/O rates, Up 29d and Temp 45\u00b0C in threshold green beside 'Seen 14m ago', while the drawer led with the critical 'Host Apollo-114 is offline' alert and read 'Observed state Degraded, Uptime 29d 12h'.",
"After (this commit): both resources serve status offline, the agent/docker sightings still read stale, and health reads critical with reasons offline then telemetry_stale. The Apollo-114 row shows a red dot and '\u2014' in CPU, Memory, Disk, Net I/O, Disk I/O, Up and Temp at 1440 and 390; the status filter counts it under Offline. Spectrum-709 (reporting) keeps every value, Temp 47\u00b0C included.",
"Machine drawer at 1440 and 390: Runtime context reads 'Observed state Offline' and 'Last seen N mins ago' with no Uptime row; Platform details > Machine 'Show details' drops the System card's Uptime and the Thermals card reads 'Cpu.package 45\u00b0C (last known)' with title 'Last known reading, not current: host agent stopped reporting'.",
"Docker page at 1440 and 390: edge-proxy-01 shows a red dot, '\u2014' for CPU, Memory, Disk and Temp (the hold seeds a retained 51\u00b0C), and the existing 'No report for 16m' marker in Uptime; Edge Apps 01 keeps its values, Temp 38.5\u00b0C included.",
"Bundle check: WorkloadsSurface unchanged (70.20 kB), Standalone +0.04 kB, Docker +0.01 kB, InfrastructureWorkspace flat versus this tree without the frontend diff; all chunks within budget.",
"Not covered: a real agent going silent on hardware, light/dark differences (no new colour classes), and the drawer Disks card, which still threshold-colours a silent agent's retained filesystem usage (left for a follow-up)."
],
"interactions": [
"Enabled the Network, Disk I/O, Uptime and Temp columns through the Machines column preferences (localStorage init) and read every cell of the offline and reporting rows plus the status dot class at 1440 and 390.",
"Opened the Apollo-114 drawer by row click at both widths, read the Runtime context rows, expanded Platform details and the Machine 'Show details', and read the System and Thermals cards with their value titles.",
"Opened /docker at both widths and read every cell of edge-proxy-01 and Edge Apps 01.",
"Queried /api/resources on both stacks for status, per-source sighting status and health reasons of the held agent and Docker host."
]
}
@@ -0,0 +1,54 @@
{
"version": 1,
"base_sha": "dcb69cfb020193d05d9e4e668d19b820a0c46d31",
"verified_at": "2026-10-06T22:07:56Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts",
"frontend-modern/src/features/storageBackups/diskPresentation.ts",
"frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts",
"frontend-modern/src/types/api.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts": "6cdf797534d6ecba8f8007561195f9245899ff881a225f83c2c6c6293b557f8c",
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "98e9dc568360b5b6c9552433207e0e2fa78b91ba17da53fa513fb42484373ff5",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "a1dfacc661118c77fea482d26ffbc6d1c6aca0d351d77744f7a2dbbd4770ef6f",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts": "1f9be08ed7100a330142eab8900b04474360785e75c1b95a8a42d4bc35fa7233",
"frontend-modern/src/features/storageBackups/diskPresentation.ts": "debb0d30e95b84d010d5b7478d61ce5fa9487c0bfb43b3eb812e16b4a75acf2e",
"frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts": "c62e4d1e74b9278ef34d93e8101c3d41ab67b17885b6810116a2f96bd112301c",
"frontend-modern/src/types/api.ts": "bb2712595745499cc51ed5c6e54ea24998e00d42ce45c8715b1d6c3eed6afc3e"
},
"routes": [
"/standalone",
"/proxmox?q=analytics-batch-01"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack built from this tree (base dcb69cfb02) plus a local-only go build -overlay of internal/mock/generator.go: three standalone Linux agents get SMART rows; Apollo-114 (CPU sensors) and Meridian-731 (SMART only) are stamped as State.ExpireHostTelemetry does (status offline, temperatures kept, collection.temperature unavailable 'host agent stopped reporting', source kept), Europa-405 keeps reporting. /api/resources returned agent.sensors.smart[].collection end to end, status warning and agent.stale true for the silent agents.",
"Same-session control with the HEAD runtime files under the same Vite: Meridian-731's Temp cell rendered 52°C in threshold green, the tooltip and the drawer Thermals card listed the retained disks as plain current readings, and the guest Physical Disks card showed plain muted 52°C/41°C with no last-known cue.",
"After: Meridian-731's Temp cell renders '52°C' muted (slate-600 light, slate-400 dark) with a dotted underline, no threshold colour and screen-reader ', last known'; its tooltip lists both disks '(last known)' muted. Apollo-114 keeps its CPU package value (no collection state) while its disk tooltip rows read '(last known)'. Europa-405 is unchanged.",
"Machine drawer (Platform details > Machine details > Thermals) at desktop and phone: 'Disk nvme0n1 52°C (last known)' and 'Disk sda 41°C (last known)' with title 'Last known reading, not current: host agent stopped reporting'; cpu.package unchanged.",
"Guest drawer Physical Disks card at desktop and phone: Playwright routes add the agent version a VM-resident agent would carry to analytics-batch-01 and answer its physical_disk child query with Meridian-731's real registry disks (collection state untouched) re-parented to the VM, because mock mode never links an agent inside a VM. Both disks render muted, dotted, with the reason title and ', last known'.",
"Light and dark schemes on the Machines page. Bundle check: Standalone +0.22 kB, shared chunk +0.20 kB, WorkloadsSurface and InfrastructureWorkspace flat versus a same-machine main build, no tracked chunk renamed.",
"Not covered: a real host agent going silent on hardware, an agent genuinely running inside a VM, or stale-row CPU/memory/disk cells (flagged separately)."
],
"interactions": [
"Enabled the Temp column from View > column picker on /standalone and read every row's cell text, class, computed colour and text decoration.",
"Hovered the Temp cell of the SMART-only and the CPU-sensor silent agents and read the tooltip rows and their muted state.",
"Opened the Apollo-114 drawer by row click (desktop) and CPU cell tap (phone), expanded Platform details and the Machine Show details, and read the Thermals rows with their titles.",
"Opened the analytics-batch-01 guest drawer from the Proxmox guest table at desktop and phone and read the Physical Disks card summaries, data-temperature-reading, titles and decoration.",
"Swapped the runtime files to HEAD under the running Vite for the same-session control, then restored and re-ran every pass on the final tree."
]
}
@@ -0,0 +1,39 @@
{
"version": 1,
"base_sha": "1fe9f86d55271345492945a25ab9398892ec6bf0",
"verified_at": "2026-10-06T22:35:29Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts": "1879e91c0eb901261d6497cc0245e2a72b8d0acc72414cd69d3369d187e59fc5",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "0f710d2544c964ae36e04f5bdb1bb2e3f2db37deb9b1ad804df6909e22256fc9",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "32a8c017790a55dc230585e189bbff5cd3e81ee932a80c4379fc1be3ea61172d"
},
"routes": ["/proxmox/overview", "/vmware"],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Vite dev server serving this tree (based on PR 2622 head 1fe9f86d55) against a private DEMO_MODE mock backend (50 PVE nodes, 929 guests, 7 ESXi hosts, telemetry off), headless Chromium via Playwright; desktop 1440x900 and phone 390x844.",
"Trends mode, 95 s per page counted per 30 s poll tick, before (base files swapped back under the same server) vs after: /vmware went from 1 /api/charts/infrastructure + 1 /api/charts/workloads read per tick to 0 + 1; /proxmox/overview from 2 + 2 to 1 + 1. Same on both viewports (95 s totals: vmware 4+4 to 0+4, proxmox 8+8 to 4+4).",
"Proxmox nodes table in Trends: every rendered node row draws its CPU, memory and disk sparklines (36 of 36 on desktop, 18 of 18 on phone), same as before; guest table sparklines unchanged (Proxmox 420 desktop / 108 phone, vSphere 54).",
"Bars mode on /proxmox/overview: node table shows no sparklines and the page makes no /api/charts reads.",
"Screenshots of both tables in Trends inspected at both viewports. The only failed request is the read-only demo refusing POST /api/usage/workload-history (403), the Trends activity ping, which this change does not touch."
],
"interactions": [
"Desktop: opened the View popover and switched Bars to Trends; phone: opened Filters, then View, and switched Bars to Trends. The Trends button reports aria-pressed=true and the switch triggers exactly one workloads read and one infrastructure read.",
"Navigated between /vmware and /proxmox/overview with Trends persisted and watched three poll cycles on each page.",
"Scrolled the nodes table into view before each capture."
]
}
@@ -0,0 +1,49 @@
{
"version": 1,
"base_sha": "c3adf0c7b9fa7f4283f7fd832a806659b009b51b",
"verified_at": "2026-10-06T21:45:33Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/shared/NodeGroupHeader.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "67c2c51ffceb96893f968dc141dbce54f0affe66fb389ffeeba8c8c94988968c",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e49032445f566b4e6a4329314f4ada41ed6f7652ba52e9e3fc413047a49f7878",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "a340532f9e3e0e28d83e9a0fb369c8be973acb0dea7b321fd646345ff924db38",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "a29034c5149b7323e86bc2fe795d9b18db805b1ad32e32013fb71547256d61f5",
"frontend-modern/src/components/shared/NodeGroupHeader.tsx": "e435c1e6ab04b85a022459cf19a67645bc8339f15ef986ffcb5b42ba27ce667f",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "80c860be3bee37251c16d21044074267e48991b9cb39b87d335cb2155066ec96",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "6c74063a298a259ce5f60485174529e9bffbe7293ae0710f53dc95d6f3c11265"
},
"routes": ["/proxmox/overview", "/vmware"],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Production vite build of this tree (rebased on c3adf0c7b9) embedded in a private DEMO_MODE mock backend (50 PVE nodes, 929 guests, 7 ESXi hosts), headless Chromium via Playwright; desktop 1440x900 and phone 390x844.",
"Proxmox Overview guest table, Grouped layout: every grouped node row (8 desktop, 2 phone in the rendered window) is one td whose colspan equals the guest table's visible header count (8 desktop, 5 phone), with status dot, linked node name, cluster badge and Agent badge only; no data-workload-col cells, no PVE version, temperature or uptime text.",
"vSphere Overview VM table: the 7 host group rows render as one spanning cell (colspan 6 desktop, 5 phone) with host name and cluster badge, unchanged shape.",
"Bars and Trends metric modes both inspected: Trends adds four sparklines per guest row (Proxmox 140 to 560 images, vSphere 18 to 72) while each grouped host row keeps exactly its one status-dot image.",
"Screenshots of grouped rows in both modes and of open guest drawers inspected at both viewports; no page errors.",
"This is the shape both pages already rendered on main, where compactGroupHeaders was always set; it is not a check of host drawers, which both pages disable."
],
"interactions": [
"Opened the View popover (desktop) and the Filters popover (phone) and toggled Bars to Trends and back.",
"Clicked a grouped host row on each page: no inline node drawer row appears and nothing else changes.",
"Clicked the first guest row under a group on each page: its inline guest drawer opens.",
"Scrolled the first grouped host row to the viewport centre before each capture."
]
}
@@ -0,0 +1,44 @@
{
"version": 1,
"base_sha": "8259a74c9d0503df630b09c12a2c1be906fa3d78",
"verified_at": "2026-10-06T22:20:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/storageBackups/diskPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/features/storageBackups/diskPresentation.ts": "87469b0d62a31a1657a94a5f645041ad34987cc5af55ca238c6cbf49b0c068ee"
},
"routes": [
"/proxmox/storage?tab=disks",
"/proxmox/storage?tab=disks&status=attention",
"/proxmox/storage?tab=disks&status=critical",
"/proxmox/storage?tab=disks&status=warning"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack (8 nodes) whose generator was overlaid locally, never committed, to pin readings from a control file on pve3: Crucial MX500 2TB /dev/nvme2 (SMART PASSED) at 72C, WD Blue SN570 /dev/sd1 at 63C, and Crucial MX500 2TB /dev/nvme0 at 72C with 2 pending sectors. The API returned risk {critical: [temperature_high]}, {warning: [temperature_high]} and {critical: [pending_sectors, temperature_high]} respectively; the backend binary was rebuilt from base 8259a74c9d (includes #2611)",
"Same-session control with the pre-change diskPresentation.ts (3ac5664d95, whose verdict code is unchanged on base 8259a74c9d) swapped in under the running Vite: the 72C temperature-only disk read 'Replace Now' (phone 'Replace') titled 'Disk temperature is 72C' in the row and in its drawer, and the 63C disk read 'Needs Attention' and sorted above the 7%-life disk",
"After the change at 1440 (full layout): rows sort Replace Now (pending sectors, title 'Pending sectors detected (2)', rgb(185,28,28)), Running Hot (title 'Disk temperature is 72C', rgb(185,28,28)), Needs Attention (970 EVO, 'SSD life remaining is 7%', rgb(180,83,9)), Running Hot (title 'Disk temperature is 63C', rgb(180,83,9)), then Healthy/Unknown disks unchanged",
"After the change at 390 (narrow layout): the same rows show the compact words Replace, Hot, Attention, Hot with the same tones and titles",
"Disk drawer header for the 72C temperature-only disk reads 'Running Hot Disk temperature is 72C' at both widths",
"Health filter from the URL at both widths: status=attention lists the two Replace/Hot critical rows plus Attention and the amber Hot row; status=critical lists only Replace Now and the red Running Hot; status=warning lists Needs Attention and the amber Running Hot",
"Known and unchanged: the 63C NVMe reads Running Hot in amber beside a green Temp cell, because the risk tier is the server's flat 60C while the Temp cell uses the NVMe alert threshold; the same disk read Needs Attention before this change. Threshold reconciliation is separate work",
"Amended content (empty-summary reasons keep their code and severity, an unexplained critical level no longer borrows a weaker reason's text): full pass repeated at 1440 and 390 with identical rows, compact words, titles, colours, drawer header and status=attention/critical/warning results"
],
"interactions": [
"Loaded the Physical Disks tab unfiltered at 1440x900 and 390x844 and read every row's visible health word, its title, computed colour and the Temp cell",
"Opened the inline drawer for the 72C temperature-only disk by clicking its row at both widths and read the health header",
"Loaded status=attention, status=critical and status=warning at both widths and read the listed rows",
"Swapped the pre-change diskPresentation.ts in under the running Vite, repeated the reads, restored the change and confirmed it byte-identical with cmp; after rebasing onto 8259a74c9d repeated the full after-change pass with a rebuilt backend"
]
}
@@ -0,0 +1,60 @@
{
"version": 1,
"base_sha": "1b6a9b45d7a85ae2add1ff4213ef80f6b35f7309",
"verified_at": "2026-10-06T22:49:34Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Storage/DiskList.tsx",
"frontend-modern/src/components/Storage/useDiskDetailModel.ts",
"frontend-modern/src/components/Storage/useDiskListModel.ts",
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx",
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts",
"frontend-modern/src/features/storageBackups/diskPresentation.ts",
"frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/truenasPageModel.ts",
"frontend-modern/src/utils/metricThresholds.ts",
"frontend-modern/src/utils/temperature.ts"
],
"content_sha256": {
"frontend-modern/src/components/Storage/DiskList.tsx": "9fbcdb4659cfdf443f521c9b15969f485876cbdde8d7503110e12325b1899538",
"frontend-modern/src/components/Storage/useDiskDetailModel.ts": "51dea57998a0c0d88f4a8d6410ff16dc0559674cfecf456b61706bac145321af",
"frontend-modern/src/components/Storage/useDiskListModel.ts": "778db34c9def3a921e4bb6f9a36a79dcaa804962e9fb5bc3c03d908c585d8503",
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "ef649564bd14366aa71121e9c09ac4309bcd299fd1d388ceff5d5969671ae471",
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "dac5b69aa5c603b719946fc3262d810a5a9bdc86469f33c3097542516f73d5cd",
"frontend-modern/src/features/storageBackups/diskPresentation.ts": "4210a0e7d61113f774462571700582d2f32cfe4474f04048e03d728272bd2247",
"frontend-modern/src/features/storageBackups/diskTemperaturePresentation.ts": "f983ea771d8f21ab018ca38a802aa7fa9795860c0ff010adcb7507cf9c47678c",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "168af9011efa41fc545dfb198f6eff5e9d5dbb003a0cd330c1238a820979c6b9",
"frontend-modern/src/features/truenas/truenasPageModel.ts": "36497cd8a519e3de9564eefb4b1a245fac6b0478c9f9a44a262c24228fc33dc9",
"frontend-modern/src/utils/metricThresholds.ts": "227994b089456c54489d64c90d3a3111421ec0c1b13adddc2744ab2d3749cfc2",
"frontend-modern/src/utils/temperature.ts": "a67d4ec80af39b6057ed155dc8ce5ce4c7e697345f7b9fadb798dcbd31605f9f"
},
"routes": ["/proxmox/storage?tab=disks&q=West%20Production", "/truenas/storage"],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack built from this exact staged content (embedded UI and backend from one tree). A local-only generator overlay, never committed, pinned Proxmox disks from a control file: Samsung 990 PRO NVMe 63C, Crucial MX500 SATA 56C, WD Black SN850X NVMe 72C, Seagate IronWolf SATA 52C, plus TrueNAS mock disk sda (SATA) at 57C. The API returned no physicalDisk.risk and status online for every pinned disk",
"Same-session control on a second private stack built from the base without this change (main plus #2623): NVMe 63C read 'Running Hot' (amber) beside a green 63C cell with risk warning [temperature_high] and status warning; SATA 56C read 'Healthy' beside a red 56C cell; with the NVMe trigger raised to 75 the NVMe 72C still read 'Running Hot' from the flat 70C rule beside an amber cell",
"After, factory thresholds, 1440: Crucial MX500 56C 'Running Hot' red, title 'Disk temperature is 56\u00b0C, at or above its 55\u00b0C alert threshold.', red 56C cell, sorted first; WD Black 72C 'Running Hot' red with its 70C threshold; Samsung 990 PRO 63C 'Healthy' with a green cell; Crucial MX500 51C 'Healthy' with an amber cell (the band below the trigger stays colour only)",
"After, factory thresholds, 390: same rows with compact 'Hot' in red beside red cells, 'Healthy' beside the green 63C NVMe; no horizontal overflow (scrollWidth 390)",
"After, NVMe trigger raised to 75/70 through PUT /api/alerts/config: WD Black NVMe 72C is no longer Running Hot (its mock SMART health is UNKNOWN so it reads 'Unknown') and its cell is amber; SATA 56C still Running Hot",
"After, agent Disk Temp default switched off (trigger 0) through PUT /api/alerts/config: no disk reads Running Hot and every Temp cell is uncoloured (text-base-content); TrueNAS sda reads healthy with an Online dot",
"After, disk drawer for the 56C SATA disk: header 'Running Hot Disk temperature is 56\u00b0C, at or above its 55\u00b0C alert threshold.' and the Temperature card reads 56\u00b0C",
"After, TrueNAS Storage at 1440: sda (57C) is an Attention row with Health 'Disk temperature is 57\u00b0C, at or above its 55\u00b0C alert threshold.'; the Attention status filter lists it; other disks healthy and unchanged",
"After, TrueNAS Storage at 390: the Health column is phone-hidden, and sda's status dot reads Warning (amber) while cool disks stay Online (green)"
],
"interactions": [
"Loaded Physical Disks filtered to West Production at 1440x900 and 390x844 and read each row's health word, title, colour and Temp cell class on both the before and after stacks",
"Changed diskTempByType.nvme to 75/70, then agentDefaults.diskTemperature to 0, through GET/PUT /api/alerts/config and reloaded Physical Disks and TrueNAS Storage after each, then restored the factory config",
"Opened the inline drawer of the 56C SATA disk by clicking its row",
"Loaded TrueNAS Storage at 1440 and 390, clicked the Attention status filter, and read sda's Health cell, title and status dot"
]
}
@@ -0,0 +1,30 @@
{
"version": 1,
"base_sha": "396bb5d517f14466795a2ccd2ff438fdba0ab077",
"verified_at": "2026-10-06T23:21:38Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/features/truenas/truenasPageModel.ts"],
"content_sha256": {
"frontend-modern/src/features/truenas/truenasPageModel.ts": "91d7afa5bfb5b47692def18ae0a6e773ca47194d41faf0b57fa5c5c82d57d278"
},
"routes": ["/truenas/storage"],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack built from this exact content, with a local-only overlay (never committed) pinning TrueNAS mock disks: sdc (SAS, carries the native incident 'Device /dev/sdc has SMART test failures.') at 67C and sda (SATA, no incident) at 57C, factory disk temperature thresholds (SAS 65C, SATA 55C)",
"1440: sdc's Health cell reads 'Device /dev/sdc has SMART test failures.' with '+1', and its title lists that reason then 'Disk temperature is 67\u00b0C, at or above its 65\u00b0C alert threshold.'; before this change the heat reason was dropped whenever an incident had text. sda still reads 'Disk temperature is 57\u00b0C, at or above its 55\u00b0C alert threshold.'",
"390: the Health column stays phone-hidden; sda's status dot reads Warning and the cool sdd reads Online; no horizontal overflow (scrollWidth 390)"
],
"interactions": [
"Loaded TrueNAS Storage at 1440x900 and 390x844 and read the sdc and sda Health cells, their titles and visible reason text, and the status dot titles",
"Moved sdc's pinned reading from 57C (under its SAS trigger: incident only, no heat) to 67C and reloaded to confirm the heat reason joins the incident text"
]
}
@@ -0,0 +1,35 @@
{
"version": 1,
"base_sha": "94b7870855120b2c62fce19102903601b9fc4bfc",
"verified_at": "2026-10-06T22:30:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "6e4003a1508856b944680e35234d9f93ef59a02f870fea6f9f1173f7e641c905"
},
"routes": [
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack (8 nodes, local uncommitted generator overlay pinning pve3 /dev/nvme2 at 72C). Mock guests carry no agent facet and no linked physical disks, so Playwright route interception added agent.agentVersion '6.5.0' to redis-cache-01 (vm-3ae77f2c237ff912) in /api/resources responses and answered the guest's type=physical_disk&parent=<guest id> query with the stack's real 72C Crucial MX500 resource reparented to that guest",
"Production builds: with this commit (base 8259a74c9d plus the verdict commit 94b7870855) check-bundle-size reports WorkloadsSurface 64.31 kB gzip against a 70.23 kB max, every tracked chunk ok and none missing; origin/main d9ffb39ce8 plus 94b7870855 without this commit measured 70.64 kB (over by 0.40) and d9ffb39ce8 alone 70.20 kB. diskPresentation and GuestPhysicalDisks now build as their own chunks and the WorkloadsSurface chunk no longer contains the disk verdict strings",
"Guest drawer at 1440 and 390: the GuestPhysicalDisks module is requested only after the guest row is clicked open (none before), the parent query is sent once for the guest id, and the card reads 'Physical Disks & SMART (1)', 'Crucial MX500 2TB', '/dev/nvme2', 'Running Hot' (title 'Disk temperature is 72C', rgb(185,28,28)), '72\u00b0C', '1.82 TB'"
],
"interactions": [
"Loaded /proxmox/overview at 1440x900 and 390x844, recorded module requests, then clicked the redis-cache-01 guest row to open its drawer and waited for the guest physical-disk card",
"Read the card text, the verdict label, its title and computed colour, and clipped a screenshot of the card at both widths",
"Ran vite build and check-bundle-size on d9ffb39ce8, on d9ffb39ce8 plus 94b7870855, and on this branch"
]
}
@@ -217,9 +217,13 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
const now = useRelativeTimeNow();
const sections = (): DetailSection[] => {
const docker = dockerContainerMeta(props.resource);
// An offline resource has no current uptime: the retained value is from
// its last report, and the Last seen row already says when that was.
const uptimeSeconds = docker
? getDockerContainerUptimeSeconds(props.resource)
: props.resource.uptime;
: props.resource.status === 'offline'
? undefined
: props.resource.uptime;
const identityRows = compactDetailRows([
...props.drawer.primaryIdentityRows().map((row) => makeDetailRow(row.label, row.value)),
props.showPlatformId ? makeDetailRow('Platform ID', props.resource.platformId) : null,
@@ -0,0 +1,64 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { InlineResourceSummaryTables } from '../ResourceDetailSummary';
import type { UseResourceDetailDrawerStateResult } from '../useResourceDetailDrawerState';
const drawerState = {
primaryIdentityRows: () => [],
identityIpValues: () => [],
identityAliasValues: () => [],
aliasPreviewValues: () => [],
hasAliasOverflow: () => false,
identityCardHasRichData: () => true,
sourceSummary: () => null,
lastSeen: () => '14 mins ago',
lastSeenAbsolute: () => '',
} as unknown as UseResourceDetailDrawerStateResult;
const machine = (status: Resource['status']): Resource => ({
id: 'agent-apollo',
name: 'Apollo-114',
displayName: 'Apollo-114',
platformId: 'host-linux-1',
platformType: 'agent',
sourceType: 'agent',
type: 'agent',
status,
uptime: 2_500_000,
lastSeen: Date.parse('2026-10-06T22:00:00Z'),
});
afterEach(cleanup);
describe('resource drawer runtime context for an offline resource', () => {
it('drops the uptime of an offline machine and keeps when it was last seen', () => {
render(() => (
<InlineResourceSummaryTables
resource={machine('offline')}
drawer={drawerState}
showPlatformId={false}
/>
));
const section = screen.getByTestId('resource-runtime-context-section');
expect(within(section).getByText('offline')).toBeInTheDocument();
expect(within(section).queryByText('Uptime')).toBeNull();
expect(within(section).getByText('Last seen')).toBeInTheDocument();
expect(within(section).getByText('14 mins ago')).toBeInTheDocument();
});
it('keeps the uptime of a machine that is up', () => {
render(() => (
<InlineResourceSummaryTables
resource={machine('online')}
drawer={drawerState}
showPlatformId={false}
/>
));
const section = screen.getByTestId('resource-runtime-context-section');
expect(within(section).getByText('Uptime')).toBeInTheDocument();
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildCustomSensorRows,
buildTemperatureRows,
HOST_AGENT_STOPPED_REPORTING_REASON,
formatInteger,
formatSensorName,
formatSourceType,
@@ -10,7 +11,7 @@ import {
} from '@/components/Infrastructure/resourceDetailMappers';
import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw';
import resourceDetailDiscoveryModelSource from '@/components/Infrastructure/resourceDetailDiscoveryModel.ts?raw';
import type { Resource } from '@/types/resource';
import type { PhysicalDiskCollectionStatus, Resource } from '@/types/resource';
const createHybridHostResource = (): Resource =>
({
@@ -203,6 +204,120 @@ describe('resourceDetailMappers', () => {
},
]);
});
it('marks a silent agent disk temperature as last known and drops disks without one', () => {
const rows = buildTemperatureRows({
smart: [
{
device: 'sda',
temperature: 71,
// A host agent past its reporting lease keeps its last reading.
collection: {
temperature: {
state: 'unavailable',
source: 'host_agent',
reason: 'host agent stopped reporting',
},
},
},
{
device: 'sdb',
temperature: 38,
collection: { temperature: { state: 'available', source: 'smartctl' } },
},
{
device: 'sdc',
temperature: 0,
collection: {
temperature: {
state: 'unsupported',
source: 'smartctl',
reason: 'device did not expose a temperature reading',
},
},
},
],
});
expect(rows).toEqual([
{
label: 'Disk sda',
value: '71°C (last known)',
valueTitle: 'Last known reading, not current: host agent stopped reporting',
},
{ label: 'Disk sdb', value: '38°C', valueTitle: '38.0°C' },
]);
});
it("marks a silent agent's sensor readings last known and leaves disks to their own state", () => {
const rows = buildTemperatureRows(
{
thermalState: { pressure: 'serious' },
temperatureCelsius: { 'cpu.package': 45 },
fanRpm: { chassis_fan: 1200 },
smart: [
{
device: 'sda',
temperature: 38,
collection: { temperature: { state: 'available', source: 'smartctl' } },
},
],
},
{ lastKnownReason: HOST_AGENT_STOPPED_REPORTING_REASON },
);
const lastKnown = 'Last known reading, not current: host agent stopped reporting';
expect(rows).toEqual([
{ label: 'Thermal pressure', value: 'Serious (last known)', valueTitle: lastKnown },
{ label: 'Cpu.package', value: '45°C (last known)', valueTitle: lastKnown },
{ label: 'Chassis Fan', value: '1,200 RPM (last known)', valueTitle: lastKnown },
// A disk row follows its own collection state, which the backend
// withdraws when the lease expires.
{ label: 'Disk sda', value: '38°C', valueTitle: '38.0°C' },
]);
expect(buildTemperatureRows({ temperatureCelsius: { 'cpu.package': 45 } })).toEqual([
{ label: 'Cpu.package', value: '45°C', valueTitle: '45.0°C' },
]);
});
it('lists a SMART row only for a positive reading in any state, never a standby disk', () => {
const states: (PhysicalDiskCollectionStatus | undefined)[] = [
undefined,
{ temperature: { state: 'available', source: 'smartctl' } },
{ temperature: { state: 'unavailable', source: 'smartctl' } },
];
for (const collection of states) {
for (const temperature of [0, -3]) {
expect(
buildTemperatureRows({ smart: [{ device: 'sda', temperature, collection }] }),
).toEqual([]);
}
}
expect(
buildTemperatureRows({
smart: [
{
device: 'sda',
temperature: 44,
standby: true,
collection: { temperature: { state: 'unavailable', source: 'smartctl' } },
},
{
device: 'sdb',
temperature: 39,
collection: { temperature: { state: 'missing', source: 'smartctl' } },
},
],
}),
).toEqual([
{
label: 'Disk sdb',
value: '39°C (last known)',
valueTitle: 'Last known reading, not current',
},
]);
});
});
describe('buildCustomSensorRows', () => {
@@ -359,6 +474,15 @@ describe('resourceDetailMappers', () => {
expect(agent?.id).not.toBe('resource:host:hash-1');
});
it('reports no uptime for a silent agent, whose last report is not how long it has been up', () => {
const base = createHybridHostResource();
const reporting = { ...base, uptime: 2_500_000 } as Resource;
expect(toAgentFromResource(reporting)?.uptimeSeconds).toBe(2_500_000);
const silent = { ...reporting, agent: { stale: true } } as Resource;
expect(toAgentFromResource(silent)?.uptimeSeconds).toBe(0);
});
it('preserves the local infrastructure display name for governed resources', () => {
const agent = toAgentFromResource({
...createHybridHostResource(),
@@ -15,6 +15,7 @@ import type {
} from '@/types/api';
import type { Resource, ResourceMetric, ResourceVMwareMeta } from '@/types/resource';
import { formatTemperature } from '@/utils/temperature';
import { getPhysicalDiskTemperaturePresentation } from '@/features/storageBackups/diskTemperaturePresentation';
import { formatBytes } from '@/utils/format';
import { getActionableAgentIdFromResource } from '@/utils/agentResources';
import {
@@ -316,7 +317,9 @@ export const toAgentFromResource = (
sensors: agent.sensors,
raid: agent.raid,
status: resource.status,
uptimeSeconds: agent.uptimeSeconds ?? resource.uptime ?? 0,
// A silent agent's uptime is from its last report, not how long the
// machine has been up.
uptimeSeconds: resource.agent?.stale ? 0 : (agent.uptimeSeconds ?? resource.uptime ?? 0),
lastSeen: resource.lastSeen,
agentVersion: agent.agentVersion,
commandsEnabled: agent.commandsEnabled,
@@ -399,7 +402,23 @@ const buildTypedGPUTemperatureKeys = (gpus?: HostSensorSummary['gpu']) => {
return keys;
};
export const buildTemperatureRows = (sensors?: HostSensorSummary) => {
// Matches the backend's reason for readings a host agent past its reporting
// lease no longer collects (models.HostAgentStoppedReportingReason).
export const HOST_AGENT_STOPPED_REPORTING_REASON = 'host agent stopped reporting';
export type TemperatureRowsOptions = {
/**
* Why the machine's own sensor readings are retained rather than current,
* such as a host agent past its reporting lease. Disk rows keep their own
* collection state instead.
*/
lastKnownReason?: string;
};
export const buildTemperatureRows = (
sensors?: HostSensorSummary,
options: TemperatureRowsOptions = {},
) => {
const rows: { label: string; value: string; valueTitle?: string }[] = [];
const thermalState = sensors?.thermalState;
const typedGPUTemperatureKeys = buildTypedGPUTemperatureKeys(sensors?.gpu);
@@ -499,16 +518,31 @@ export const buildTemperatureRows = (sensors?: HostSensorSummary) => {
});
}
// This card has no other cue that a value is not current, so a retained
// sensor reading says so in its value, as a retained disk reading does.
const lastKnownReason = options.lastKnownReason?.trim();
if (lastKnownReason) {
const title = `Last known reading, not current: ${lastKnownReason}`;
for (const row of rows) {
row.value = `${row.value} (last known)`;
row.valueTitle = title;
}
}
const smart = sensors?.smart;
if (smart) {
smart
.filter((disk) => !disk.standby && Number.isFinite(disk.temperature))
.filter((disk) => !disk.standby)
.sort((a, b) => a.device.localeCompare(b.device))
.forEach((disk) => {
const temperature = getPhysicalDiskTemperaturePresentation(disk);
if (!temperature) return;
// A retained reading, such as a silent agent's, says so in the value
// because this card has no other cue that it is not current.
rows.push({
label: `Disk ${disk.device}`,
value: formatTemperature(disk.temperature),
valueTitle: `${disk.temperature.toFixed(1)}°C`,
value: temperature.current ? temperature.label : `${temperature.label} (last known)`,
valueTitle: temperature.title ?? `${disk.temperature.toFixed(1)}°C`,
});
});
}
@@ -35,6 +35,7 @@ import type { ResourceIntelligence } from '@/types/aiIntelligence';
import {
buildCustomSensorRows,
buildTemperatureRows,
HOST_AGENT_STOPPED_REPORTING_REASON,
toAgentFromResource,
toNodeFromProxmox,
type AgentPlatformData,
@@ -201,7 +202,13 @@ export const useResourceDetailDrawerDerivedState = (
[],
);
const agentInfo = createMemo(() => toAgentFromResource(resource(), agentMeta()));
const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors));
// A silent agent's sensors are its last report, even on a row another
// source keeps online.
const temperatureRows = createMemo(() =>
buildTemperatureRows(agentInfo()?.sensors, {
lastKnownReason: resource().agent?.stale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined,
}),
);
const customSensorRows = createMemo(() => buildCustomSensorRows(agentInfo()?.sensors));
const dockerHostData = createMemo(() => platformData()?.docker as DockerPlatformData | undefined);
@@ -13,7 +13,6 @@ import {
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { formatBytes } from '@/utils/format';
import { getTemperatureTextClass } from '@/utils/temperature';
import {
@@ -77,6 +76,7 @@ import {
getPhysicalDiskEmptyStatePresentation,
getPhysicalDiskHealthCompactLabel,
getPhysicalDiskHealthStatus,
getPhysicalDiskTemperatureThresholds,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
getPhysicalDiskLifeLabel,
@@ -132,7 +132,6 @@ export const DiskList: Component<DiskListProps> = (props) => {
};
const columnStyle = (columnId: PhysicalDiskTableColumnId) =>
getPhysicalDiskColumnWidthStyle(layoutMode(), columnId);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const model = useDiskListModel({
disks: () => props.disks,
nodes: () => props.nodes,
@@ -523,7 +522,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
reading().current
? getTemperatureTextClass(
data().temperature,
getDiskTemperatureThresholds(data().type),
getPhysicalDiskTemperatureThresholds(data()),
'diskTemperature',
)
: PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS
@@ -2,8 +2,12 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/te
import { createSignal } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertsAPI } from '@/api/alerts';
import type { AlertConfig } from '@/types/alerts';
import type { Resource } from '@/types/resource';
import { DiskList } from '@/components/Storage/DiskList';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { eventBus } from '@/stores/events';
vi.mock('@/components/Storage/DiskDetail', () => ({
DiskDetail: (props: { disk: Resource }) => <div data-testid="disk-detail">{props.disk.id}</div>,
@@ -343,6 +347,134 @@ describe('DiskList', () => {
);
});
it('says a disk that is only hot is running hot, not due for replacement', () => {
const view = renderDiskList({
disks: [
buildDisk('sda', 'pve3', {
model: 'Crucial MX500 2TB',
diskType: 'sata',
wearout: 95,
temperature: 56,
}),
buildDisk('sdb', 'pve3', {
temperature: 72,
smart: { pendingSectors: 2 },
risk: {
level: 'critical',
reasons: [
{
code: 'pending_sectors',
severity: 'critical',
summary: 'Pending sectors detected (2)',
},
],
},
}),
buildDisk('nvme0n1', 'pve3', {
model: 'Samsung 990 PRO',
diskType: 'nvme',
temperature: 63,
}),
],
nodes: [buildNode('node-pve3', 'pve3')],
selectedNode: null,
searchTerm: '',
});
const rows = Array.from(view.container.querySelectorAll('[data-row-id]'));
// The disk that needs replacing sorts above the one that needs cooling.
expect(rows.map((row) => row.getAttribute('data-row-id'))).toEqual(['sdb', 'sda', 'nvme0n1']);
const hotRow = within(rows[1] as HTMLElement);
expect(hotRow.queryByText('Replace Now')).not.toBeInTheDocument();
expect(hotRow.getByText('Running Hot')).toHaveClass(
'platform-table-label-full',
'text-red-700',
);
expect(hotRow.getByText('Running Hot')).toHaveAttribute(
'title',
'Disk temperature is 56°C, at or above its 55°C alert threshold.',
);
expect(hotRow.getByText('Hot')).toHaveClass('platform-table-label-compact', 'text-red-700');
// The Temp cell beside it reads red too: the same SATA alert trigger.
expect(hotRow.getByText('56°C')).toHaveClass('text-red-600');
const failingRow = within(rows[0] as HTMLElement);
expect(failingRow.getByText('Replace Now')).toHaveAttribute(
'title',
'Pending sectors detected (2)',
);
// An NVMe at 63C is under its 70C trigger and its 65C warning colour.
const nvmeRow = within(rows[2] as HTMLElement);
expect(nvmeRow.getByText('Healthy')).toBeInTheDocument();
expect(nvmeRow.getByText('63°C')).toHaveClass('text-green-600');
});
it('follows a disk from warm to hot and back to healthy', async () => {
const [disks, setDisks] = createStore({
items: [buildDisk('sda', 'pve3', { temperature: 52 })],
});
const [healthFilter, setHealthFilter] = createSignal<'all' | 'critical'>('all');
const view = render(() => (
<DiskList
disks={disks.items}
nodes={[]}
selectedNode={null}
searchTerm=""
healthFilter={healthFilter()}
selectedDiskId={null}
onSelectedDiskChange={() => {}}
/>
));
const row = () => view.container.querySelector('[data-row-id="sda"]') as HTMLElement | null;
// Warm: the Temp cell warns in amber, the verdict stays Healthy.
expect(within(row()!).getByText('52°C')).toHaveClass('text-amber-600');
expect(within(row()!).getByText('Healthy')).toBeInTheDocument();
setHealthFilter('critical');
await waitFor(() => expect(row()).toBeNull());
setDisks('items', reconcile([buildDisk('sda', 'pve3', { temperature: 56 })]));
await waitFor(() => expect(row()).not.toBeNull());
expect(within(row()!).getByText('Running Hot')).toHaveClass('text-red-700');
setHealthFilter('all');
setDisks('items', reconcile([buildDisk('sda', 'pve3', { temperature: 41 })]));
await waitFor(() => expect(within(row()!).getByText('Healthy')).toBeInTheDocument());
expect(within(row()!).queryByText('Running Hot')).not.toBeInTheDocument();
});
it('judges heat by the NVMe trigger the user raised in Alerts', async () => {
const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({
enabled: true,
activationState: 'active',
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: {
nvme: { trigger: 75, clear: 70 },
sas: { trigger: 65, clear: 60 },
sata: { trigger: 55, clear: 50 },
},
} as unknown as AlertConfig);
try {
const view = renderDiskList({
disks: [buildDisk('nvme0n1', 'pve3', { diskType: 'nvme', temperature: 72 })],
nodes: [],
selectedNode: null,
searchTerm: '',
});
const row = () =>
within(view.container.querySelector('[data-row-id="nvme0n1"]') as HTMLElement);
// Factory thresholds until the user's configuration loads: 72C passes
// the 70C NVMe trigger.
expect(row().getByText('Running Hot')).toBeInTheDocument();
expect(row().getByText('72°C')).toHaveClass('text-red-600');
await useAlertsActivation().refreshConfig();
await waitFor(() => expect(row().getByText('Healthy')).toBeInTheDocument());
expect(row().getByText('72°C')).toHaveClass('text-amber-600');
} finally {
getConfig.mockRestore();
eventBus.emit('org_switched', 'default');
}
});
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
renderDiskList({
disks: [
@@ -3,6 +3,7 @@ import type { HistoryTimeRange } from '@/api/charts';
import {
getPhysicalDiskCollectionMessages,
extractPhysicalDiskPresentationData,
getPhysicalDiskTemperatureThresholds,
type PhysicalDiskPresentationData,
} from '@/features/storageBackups/diskPresentation';
import {
@@ -23,11 +24,11 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
const { getDiskTemperatureThresholds } = useAlertsActivation();
const diskData = createMemo<PhysicalDiskPresentationData>(() =>
extractPhysicalDiskPresentationData(options.disk()),
extractPhysicalDiskPresentationData(options.disk(), getDiskTemperatureThresholds),
);
const historyResourceId = createMemo(() => resolvePhysicalDiskHistoryResourceId(options.disk()));
const attributeCards = createMemo(() =>
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
getDiskDetailAttributeCards(diskData(), getPhysicalDiskTemperatureThresholds(diskData())),
);
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData()));
@@ -9,6 +9,7 @@ import {
filterAndSortPhysicalDisks,
type PhysicalDiskPresentationData,
} from '@/features/storageBackups/diskPresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { matchesPhysicalDiskNode } from './diskResourceUtils';
type UseDiskListModelOptions = {
@@ -26,13 +27,19 @@ type UseDiskListModelOptions = {
export const useDiskListModel = (options: UseDiskListModelOptions) => {
const hasPVENodes = createMemo(() => options.nodes().length > 0);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const diskDataById = createMemo(() => buildPhysicalDiskPresentationDataMap(options.disks()));
// Each disk's heat is judged by the user's alert disk temperature thresholds,
// so the presentation data recomputes when the alert configuration loads.
const diskDataById = createMemo(() =>
buildPhysicalDiskPresentationDataMap(options.disks(), getDiskTemperatureThresholds),
);
const roleFilterOptions = createMemo(() => buildPhysicalDiskRoleFilterOptions(options.disks()));
const groupFilterOptions = createMemo(() => buildPhysicalDiskGroupFilterOptions(options.disks()));
const getDiskData = (disk: Resource): PhysicalDiskPresentationData =>
diskDataById().get(disk.id) ?? extractPhysicalDiskPresentationData(disk);
diskDataById().get(disk.id) ??
extractPhysicalDiskPresentationData(disk, getDiskTemperatureThresholds);
const selectedNodeResource = createMemo(
() => options.nodes().find((node) => node.id === options.selectedNode()) ?? null,
@@ -1,4 +1,4 @@
import { Show } from 'solid-js';
import { Show, Suspense, lazy } from 'solid-js';
import { formatDiscoveryAge } from '@/api/discovery';
import {
@@ -29,11 +29,19 @@ import {
} from './guestDrawerModel';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness';
import { GuestPhysicalDisks } from './GuestPhysicalDisks';
import { RaidCard } from '@/components/shared/cards/RaidCard';
import type { GuestDrawerProps } from './guestDrawerModel';
// Linked physical disks render only inside an open agent guest's drawer. Load
// them on demand so the disk presentation module stays out of the
// WorkloadsSurface table chunk.
const GuestPhysicalDisks = lazy(() =>
import('./GuestPhysicalDisks').then(({ GuestPhysicalDisks }) => ({
default: GuestPhysicalDisks,
})),
);
interface GuestDrawerOverviewProps {
guest: GuestDrawerProps['guest'];
guestOsSummary: string;
@@ -317,7 +325,9 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
props.guest.agentRaid?.length
}
>
<GuestPhysicalDisks parentId={props.guest.canonicalResourceId ?? props.guest.id} />
<Suspense fallback={null}>
<GuestPhysicalDisks parentId={props.guest.canonicalResourceId ?? props.guest.id} />
</Suspense>
</Show>
<Show when={props.guest.agentRaid?.length}>
<RaidCard arrays={props.guest.agentRaid} title="Guest RAID" />
@@ -5,9 +5,13 @@ import {
extractPhysicalDiskPresentationData,
getPhysicalDiskHealthStatus,
} from '@/features/storageBackups/diskPresentation';
import {
PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS,
getPhysicalDiskTemperaturePresentation,
} from '@/features/storageBackups/diskTemperaturePresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
import type { Resource } from '@/types/resource';
import { formatBytes } from '@/utils/format';
import { formatTemperature } from '@/utils/temperature';
// SMART history and chart controls are only needed after a disk row is opened.
// Keep their Storage detail module out of the initial Workloads surface.
@@ -17,8 +21,10 @@ const LazyDiskDetail = lazy(() =>
const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => {
const [expanded, setExpanded] = createSignal(false);
const data = () => extractPhysicalDiskPresentationData(props.disk);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const data = () => extractPhysicalDiskPresentationData(props.disk, getDiskTemperatureThresholds);
const health = () => getPhysicalDiskHealthStatus(data());
const temperature = () => getPhysicalDiskTemperaturePresentation(data());
const label = () => data().model || data().devPath || props.disk.name;
return (
@@ -35,8 +41,19 @@ const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => {
<span class={health().tone} title={health().summary}>
{health().label}
</span>
<Show when={data().temperature > 0}>
<span class="text-muted">{formatTemperature(data().temperature)}</span>
<Show when={temperature()}>
{(reading) => (
<span
class={reading().current ? 'text-muted' : PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS}
title={reading().title}
data-temperature-reading={reading().current ? 'current' : 'last-known'}
>
{reading().label}
<Show when={!reading().current}>
<span class="sr-only">, last known</span>
</Show>
</span>
)}
</Show>
<Show when={data().size > 0}>
<span class="text-muted">{formatBytes(data().size)}</span>
@@ -1,10 +1,6 @@
import { createMemo, For, Show } from 'solid-js';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar';
import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import {
GROUPED_TABLE_ROW_BADGE_CLASS,
getGroupedTableRowCellClass,
@@ -16,12 +12,9 @@ import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { createSummaryInteractiveRowPreviewHandlers } from '@/components/shared/summaryInteractionA11y';
import { buildSummaryDisclosureControlsId } from '@/components/shared/summaryInteractionA11y';
import { TableBody, TableCell, TableRow } from '@/components/shared/Table';
import type { Node } from '@/types/api';
import { getAlertsForResource, getAlertStyles } from '@/utils/alerts';
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
import { formatSpeed, formatUptime } from '@/utils/format';
import { isNodeOnline } from '@/utils/status';
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { getCanonicalWorkloadId, getWorkloadMetadataIdCandidates } from '@/utils/workloads';
import {
resolveSummaryGroupMemberInteractionState,
@@ -34,14 +27,12 @@ import { NodeDrawer } from './NodeDrawer';
import { buildWorkloadSummaryGroupScope } from './workloadSelectors';
import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord';
import type { WorkloadsState } from './useWorkloadsState';
import type { WorkloadTableMetric } from './workloadMetricHistoryModel';
type WorkloadPanelProps = Pick<
WorkloadsState,
| 'activeAlerts'
| 'alertsEnabled'
| 'bottomSpacerHeight'
| 'compactGroupHeaders'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
@@ -58,7 +49,6 @@ type WorkloadPanelProps = Pick<
| 'focusedSummaryWorkloadGroupScope'
| 'focusedSummaryWorkloadGroupId'
| 'hoveredSummaryWorkloadGroupScope'
| 'isMobile'
| 'nestedWorkloadContextByGuestId'
| 'nodeByInstance'
| 'search'
@@ -80,289 +70,9 @@ type WorkloadPanelProps = Pick<
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadColumnWidths'
| 'workloadTableVisibleColumns'
>;
const GROUP_NODE_METRIC_CELL_CLASS = 'px-1.5 sm:px-2 py-0.5 align-middle';
const GROUP_NODE_NAME_CELL_CLASS = getGroupedTableRowCellClass(
'py-1! pl-2! sm:pl-3! pr-1.5! sm:pr-2!',
);
const getGroupNodeColumnCellClass = (_columnId: string, isNameColumn: boolean): string => {
if (isNameColumn) return GROUP_NODE_NAME_CELL_CLASS;
return GROUP_NODE_METRIC_CELL_CLASS;
};
const normalizeMetricPercent = (value: number | null | undefined): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return value <= 1 ? Math.max(0, value * 100) : Math.max(0, value);
};
const formatMetricPercent = (value: number | null | undefined): string =>
`${Math.round(normalizeMetricPercent(value))}%`;
const getUsedPercent = (used?: number | null, total?: number | null): number => {
if (typeof used !== 'number' || typeof total !== 'number' || total <= 0) return 0;
return normalizeMetricPercent((used / total) * 100);
};
const getGroupNodeMetricKey = (node: Node): string => node.linkedAgentId || node.id || node.name;
const getGroupNodePveVersion = (node: Node): string => {
const version = (node.pveVersion || '').trim();
if (!version || version.toLowerCase() === 'unknown') return '';
return (
version.match(/pve-manager\/([^/\s]+)/i)?.[1] || version.match(/\d+(?:\.\d+)+/)?.[0] || version
);
};
const renderGroupNodeEmptyCell = () => (
<div class="text-center">
<span class="text-xs text-slate-400" aria-hidden="true">
—
</span>
</div>
);
export function WorkloadPanel(props: WorkloadPanelProps) {
const renderGroupNodeSparkline = (
node: Node,
metric: WorkloadTableMetric,
valueLabel: string,
title: string,
unit = '%',
valueLabelMode: 'inline' | 'tooltip' | 'hidden' = 'inline',
formatValue?: (value: number) => string,
) => (
<MetricMiniSparkline
series={props.workloadMetricHistory.getNodeMetricSeries(node, metric)}
valueLabel={valueLabel}
valueLabelMode={valueLabelMode}
title={title}
unit={unit}
formatValue={formatValue}
/>
);
const renderGroupNodeRatePair = (
leftLabel: string,
leftClass: string,
leftValue: number | null | undefined,
rightLabel: string,
rightClass: string,
rightValue: number | null | undefined,
) => {
const left = Math.max(0, leftValue ?? 0);
const right = Math.max(0, rightValue ?? 0);
if (left <= 0 && right <= 0) return renderGroupNodeEmptyCell();
return (
<div class="grid w-full min-w-0 grid-cols-[0.75rem_minmax(0,1fr)_0.75rem_minmax(0,1fr)] items-center gap-x-1 overflow-hidden text-[11px] tabular-nums">
<span class={`inline-flex w-3 justify-center ${leftClass}`}>{leftLabel}</span>
<span
class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted"
title={formatSpeed(left)}
>
{formatSpeed(left)}
</span>
<span class={`inline-flex w-3 justify-center ${rightClass}`}>{rightLabel}</span>
<span
class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted"
title={formatSpeed(right)}
>
{formatSpeed(right)}
</span>
</div>
);
};
const renderGroupNodeInfoCell = (node: Node) => {
const version = getGroupNodePveVersion(node);
const temperature = getCpuTemperature(node.temperature);
if (!version && temperature === null) return renderGroupNodeEmptyCell();
return (
<div class="flex items-center justify-center gap-2 text-[10px] font-medium text-muted">
<Show when={version}>
<span title="Proxmox VE version">PVE {version}</span>
</Show>
<Show when={temperature !== null}>
<span
class={getTemperatureTextClass(temperature, props.getNodeTemperatureThresholds(node))}
title="CPU temperature"
>
{formatTemperature(temperature)}
</span>
</Show>
</div>
);
};
const renderGroupNodeColumnCell = (columnId: string, node: Node) => {
switch (columnId) {
case 'type':
return (
<div class="flex justify-center">
<span
class="inline-flex items-center px-1 py-0.5 text-[10px] font-medium rounded-sm whitespace-nowrap bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
title="Proxmox VE node"
>
PVE
</span>
</div>
);
case 'info':
case 'vmid':
return renderGroupNodeInfoCell(node);
case 'cpu':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'cpu',
formatMetricPercent(node.cpu),
`${node.name} CPU history`,
'%',
'inline',
formatMetricPercent,
);
}
return (
<div class="h-4">
<EnhancedCPUBar
usage={normalizeMetricPercent(node.cpu)}
loadAverage={node.loadAverage?.[0]}
cores={props.isMobile() ? undefined : node.cpuInfo?.cores}
model={node.cpuInfo?.model}
resourceId={getGroupNodeMetricKey(node)}
/>
</div>
);
case 'memory':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'memory',
node.memory?.usageUnavailable
? 'N/A'
: formatMetricPercent(getUsedPercent(node.memory?.used, node.memory?.total)),
`${node.name} memory history`,
'%',
'inline',
formatMetricPercent,
);
}
return (
<div class="h-4">
<StackedMemoryBar
used={node.memory?.used || 0}
total={node.memory?.total || 0}
unavailable={node.memory?.usageUnavailable === true}
balloon={node.memory?.balloon || 0}
swapUsed={node.memory?.swapUsed || 0}
swapTotal={node.memory?.swapTotal || 0}
resourceId={getGroupNodeMetricKey(node)}
/>
</div>
);
case 'disk':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'disk',
formatMetricPercent(node.disk?.usage),
`${node.name} disk usage history`,
'%',
'inline',
formatMetricPercent,
);
}
return (
<div class="h-4">
<StackedDiskBar
aggregateDisk={{
total: node.disk?.total || 0,
used: node.disk?.used || 0,
free:
node.disk?.free ?? Math.max(0, (node.disk?.total || 0) - (node.disk?.used || 0)),
usage: node.disk?.usage || 0,
}}
/>
</div>
);
case 'uptime':
return (
<div class="flex justify-center">
<Show when={(node.uptime ?? 0) > 0} fallback={renderGroupNodeEmptyCell()}>
<span
class={`text-xs whitespace-nowrap ${
node.uptime > 0 && node.uptime < 3600 ? 'text-orange-500' : 'text-muted'
}`}
>
{formatUptime(node.uptime, props.isMobile())}
</span>
</Show>
</div>
);
case 'node':
case 'context':
return (
<div class="flex justify-center">
<span class="text-xs text-muted truncate max-w-[120px]" title={node.name}>
{node.name}
</span>
</div>
);
case 'os':
return (
<div class="flex justify-center">
<span class="text-xs text-muted">PVE</span>
</div>
);
case 'netIo':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'netIo',
`${formatSpeed(node.networkIn ?? 0)} / ${formatSpeed(node.networkOut ?? 0)}`,
`${node.name} network I/O history`,
'B/s',
'tooltip',
formatSpeed,
);
}
return renderGroupNodeRatePair(
'↓',
'text-emerald-500',
node.networkIn,
'↑',
'text-orange-400',
node.networkOut,
);
case 'diskIo':
if (props.workloadMetricDisplayMode() === 'sparklines') {
return renderGroupNodeSparkline(
node,
'diskIo',
`${formatSpeed(node.diskRead ?? 0)} / ${formatSpeed(node.diskWrite ?? 0)}`,
`${node.name} disk I/O history`,
'B/s',
'tooltip',
formatSpeed,
);
}
return renderGroupNodeRatePair(
'R',
'font-mono text-blue-500',
node.diskRead,
'W',
'font-mono text-amber-500',
node.diskWrite,
);
default:
return renderGroupNodeEmptyCell();
}
};
return (
<TableBody ref={props.setTableBodyRef} class="divide-y divide-border">
<Show when={props.groupedWindowing.isWindowed() && props.topSpacerHeight() > 0}>
@@ -485,22 +195,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
>
<NodeGroupHeader
node={node()!}
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
renderAs="tr"
colspan={props.totalColumns()}
columns={
props.compactGroupHeaders() ? undefined : props.workloadTableVisibleColumns()
}
columnCellClass={
props.compactGroupHeaders() ? undefined : getGroupNodeColumnCellClass
}
renderColumnCell={
props.compactGroupHeaders() ? undefined : renderGroupNodeColumnCell
}
showFactsInName={
!props.compactGroupHeaders() &&
!props.workloadTableVisibleColumnIds().includes('info')
}
trClass={
canOpenNodeDrawer()
? 'cursor-pointer select-none duration-150 [&>td>div]:flex-nowrap'
@@ -192,7 +192,6 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
activeAlerts={state.activeAlerts}
alertsEnabled={state.alertsEnabled}
bottomSpacerHeight={state.bottomSpacerHeight}
compactGroupHeaders={state.compactGroupHeaders}
getGroupLabel={state.getGroupLabel}
getNodeTemperatureThresholds={state.getNodeTemperatureThresholds}
groupedGuests={state.groupedGuests}
@@ -17,7 +17,6 @@ type WorkloadsTableProps = Pick<
| 'activeAlerts'
| 'alertsEnabled'
| 'bottomSpacerHeight'
| 'compactGroupHeaders'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
@@ -133,7 +132,6 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
activeAlerts={props.activeAlerts}
alertsEnabled={props.alertsEnabled}
bottomSpacerHeight={props.bottomSpacerHeight}
compactGroupHeaders={props.compactGroupHeaders}
getGroupLabel={props.getGroupLabel}
getNodeTemperatureThresholds={props.getNodeTemperatureThresholds}
groupedGuests={props.groupedGuests}
@@ -150,7 +148,6 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
focusedSummaryWorkloadGroupScope={props.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope}
isMobile={props.isMobile}
nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId}
nodeByInstance={props.nodeByInstance}
search={props.search}
@@ -171,7 +168,6 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
workloadMetricHistory={props.workloadMetricHistory}
workloadTableLayoutMode={props.workloadTableLayoutMode}
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
workloadColumnWidths={props.workloadColumnWidths}
/>
</Table>
@@ -1,10 +1,13 @@
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { GuestPhysicalDisks } from '../GuestPhysicalDisks';
const queryState = vi.hoisted(() => ({
resources: [] as Resource[],
// Set to a signal accessor to drive live resource updates.
accessor: undefined as (() => Resource[]) | undefined,
error: undefined as unknown,
query: '',
enabled: false,
@@ -14,7 +17,10 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
useUnifiedResources: (options: { query: string; enabled: () => boolean }) => {
queryState.query = options.query;
queryState.enabled = options.enabled();
return { resources: () => queryState.resources, error: () => queryState.error };
return {
resources: () => (queryState.accessor ? queryState.accessor() : queryState.resources),
error: () => queryState.error,
};
},
}));
@@ -41,8 +47,15 @@ const disk: Resource = {
};
describe('GuestPhysicalDisks', () => {
// The first expansion lazy-loads the Storage disk detail; a cold transform of
// that module can outlast findByText's default wait on a loaded machine.
beforeAll(async () => {
await import('@/components/Storage/DiskDetail');
}, 30_000);
beforeEach(() => {
queryState.resources = [];
queryState.accessor = undefined;
queryState.error = undefined;
queryState.query = '';
queryState.enabled = false;
@@ -84,6 +97,85 @@ describe('GuestPhysicalDisks', () => {
expect(screen.queryByTestId('guest-physical-disks')).toBeNull();
});
it('marks a retained disk temperature as last known', () => {
queryState.resources = [
disk,
{
...disk,
id: 'disk-101-b',
displayName: 'Guest NVMe',
physicalDisk: {
...disk.physicalDisk!,
devPath: '/dev/nvme0',
model: 'Guest NVMe',
temperature: 61,
collection: {
temperature: {
state: 'unavailable',
source: 'host_agent',
reason: 'host agent stopped reporting',
},
},
},
},
];
render(() => <GuestPhysicalDisks parentId="vm-resource-101" />);
const [currentRow, retainedRow] = screen.getAllByTestId('guest-physical-disk');
const current = within(currentRow).getByText('45°C');
expect(current).toHaveAttribute('data-temperature-reading', 'current');
expect(current).not.toHaveAttribute('title');
const retained = retainedRow.querySelector('[data-temperature-reading="last-known"]');
expect(retained).not.toBeNull();
expect(retained).toHaveTextContent('61°C, last known');
expect(retained).toHaveClass('underline', 'decoration-dotted', 'text-muted');
expect(retained).toHaveAttribute(
'title',
'Last known reading, not current: host agent stopped reporting',
);
});
it('follows a disk between current and last known as its collection state changes', () => {
const withReading = (
temperature: number,
collection?: NonNullable<Resource['physicalDisk']>['collection'],
): Resource => ({
...disk,
physicalDisk: { ...disk.physicalDisk!, temperature, collection },
});
const [resources, setResources] = createSignal<Resource[]>([disk]);
queryState.accessor = resources;
render(() => <GuestPhysicalDisks parentId="vm-resource-101" />);
const reading = () =>
screen.getByTestId('guest-physical-disk').querySelector('[data-temperature-reading]');
expect(reading()).toHaveAttribute('data-temperature-reading', 'current');
setResources([
withReading(61, {
temperature: {
state: 'unavailable',
source: 'host_agent',
reason: 'host agent stopped reporting',
},
}),
]);
expect(reading()).toHaveAttribute('data-temperature-reading', 'last-known');
expect(reading()).toHaveTextContent('61°C, last known');
expect(reading()).toHaveClass('decoration-dotted');
setResources([withReading(47, { temperature: { state: 'available', source: 'smartctl' } })]);
expect(reading()).toHaveAttribute('data-temperature-reading', 'current');
expect(reading()).toHaveTextContent('47°C');
expect(reading()).not.toHaveTextContent('last known');
expect(reading()).not.toHaveAttribute('title');
expect(reading()).not.toHaveClass('decoration-dotted');
setResources([withReading(0)]);
expect(reading()).toBeNull();
});
it('makes a failed child query visible rather than silently implying no disks', () => {
queryState.error = new Error('unavailable');
render(() => <GuestPhysicalDisks parentId="vm-resource-101" />);
@@ -205,6 +205,7 @@ describe('production History consumers reject unbound response evidence', () =>
onDemand: () => true,
range: () => '24h',
selectedNode: () => null,
series: 'guests',
});
return (
<output>
@@ -452,32 +452,9 @@ describe('Workloads platform-page embed contract', () => {
expect(nodesTableSource).toContain('useWorkloadTableMetricHistory');
expect(nodesTableSource).toContain('MetricMiniSparkline');
expect(nodesTableSource).toContain('isSparklineMode');
});
it('exposes compactGroupHeaders so platform pages can strip duplicate host stats from group rows', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain('compactGroupHeaders?: boolean;');
expect(stateSource).toContain('compactGroupHeaders: () => props.compactGroupHeaders === true,');
const tableSource = (await import('../WorkloadsTable.tsx?raw')).default;
expect(tableSource).toContain(`| 'compactGroupHeaders'`);
expect(tableSource).toContain('compactGroupHeaders={props.compactGroupHeaders}');
const panelSource = (await import('../WorkloadPanel.tsx?raw')).default;
expect(panelSource).toContain(`| 'compactGroupHeaders'`);
// When the flag is set the panel falls back to NodeGroupHeader's
// colspan layout: no per-column metric cells, no inline node facts.
expect(panelSource).toContain(
'props.compactGroupHeaders() ? undefined : props.workloadTableVisibleColumns()',
);
expect(panelSource).toContain(
'props.compactGroupHeaders() ? undefined : renderGroupNodeColumnCell',
);
expect(panelSource).toContain('!props.compactGroupHeaders() &&');
const proxmoxSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
.default;
expect(proxmoxSource).toContain('compactGroupHeaders');
// The hosts table draws node rows only: it polls the infrastructure
// summary and leaves guest history to the embedded workloads reader.
expect(nodesTableSource).toContain("series: 'nodes',");
});
it('lets platform pages move grouped host drawers to a dedicated host table owner', async () => {
@@ -1458,12 +1435,21 @@ describe('Workloads performance contract', () => {
expect(workloadsFilterModelSource).toContain('mobileTrailing?: JSX.Element;');
expect(workloadsStateSource).toContain('useWorkloadRouteState');
expect(workloadsStateSource).toContain('range: workloadMetricHistoryRange');
// Grouped node rows render no metric cells, so Trends on the Workloads
// surface must not poll the infrastructure summary.
expect(workloadsStateSource).toContain("series: 'guests',");
expect(workloadTableMetricHistoryStateSource).toContain(
'fetchWorkloadsSummaryAndCache(parsed.range',
);
expect(workloadTableMetricHistoryStateSource).toContain(
'fetchInfrastructureSummaryAndCache(parsed.range',
);
expect(workloadTableMetricHistoryStateSource).toContain(
"if (options.series !== 'guests' || !options.enabled()) return null;",
);
expect(workloadTableMetricHistoryStateSource).toContain(
"options.series === 'nodes' && options.enabled() ? buildHistoryScope(options.range()) : null",
);
expect(
workloadTableMetricHistoryStateSource.match(/retainPreviousValueOnSourceChange: false/g),
).toHaveLength(2);
@@ -1667,6 +1653,12 @@ describe('Workloads performance contract', () => {
expect(guestDrawerManageSource).toContain('ResourceOperatorStateSection');
expect(guestDrawerOverviewSource).toContain('buildWorkloadsDiskPresentation');
expect(guestDrawerOverviewSource).toContain('Filesystems');
// Linked physical disks load with an open agent guest's drawer, so the
// disk presentation module stays out of the WorkloadsSurface chunk.
expect(guestDrawerOverviewSource).toContain("import('./GuestPhysicalDisks')");
expect(guestDrawerOverviewSource).not.toMatch(
/^import (?!type )[^;]*from '\.\/GuestPhysicalDisks';/m,
);
// The shared DetailSectionTable gives the earlier of the last two sections
// the wider span, so the longer filesystem list must be declared before the
// short tag list for mount points to get the readable panel width.
@@ -4,10 +4,15 @@ import { createSignal, type Accessor } from 'solid-js';
import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import type { Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import { __resetInfrastructureSummaryFetchesForTests } from '@/utils/infrastructureSummaryCache';
import { useWorkloadTableMetricHistory } from '../useWorkloadTableMetricHistory';
import type { WorkloadTableMetricHistoryRange } from '../workloadMetricHistoryModel';
import {
WORKLOAD_TABLE_HISTORY_POLL_MS,
type WorkloadTableMetricHistoryRange,
} from '../workloadMetricHistoryModel';
const guest = {
id: 'cluster-a:pve1:101',
@@ -47,6 +52,7 @@ function HistoryProbe(props: {
prefetchGuests: props.prefetchGuests,
range: props.range,
selectedNode: () => null,
series: 'guests',
});
const activeHistoryValue = () => {
@@ -57,13 +63,75 @@ function HistoryProbe(props: {
return <div data-testid="active-history-value">{activeHistoryValue()}</div>;
}
const node = { id: 'cluster-a-pve1', name: 'pve1', instance: 'cluster-a' } as Node;
const point = (value: number) => [{ timestamp: 2, value, min: value, max: value }];
function SeriesProbe(props: { series: 'guests' | 'nodes' }) {
const reader = useWorkloadTableMetricHistory({
enabled: () => true,
range: () => '1h',
series: props.series,
});
const latest = (series: ReturnType<typeof reader.getGuestMetricSeries>) =>
series[0]?.points.at(-1)?.value ?? 'none';
return (
<div data-testid="series-values">
{`guest=${latest(reader.getGuestMetricSeries(guest, 'cpu'))} node=${latest(
reader.getNodeMetricSeries(node, 'cpu'),
)}`}
</div>
);
}
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
__resetInfrastructureSummaryFetchesForTests();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('useWorkloadTableMetricHistory', () => {
it.each([
['guests', 'guest=11 node=none', 'workloads'],
['nodes', 'guest=none node=22', 'infrastructure'],
] as const)(
'polls only the summary its %s rows read',
async (series, expectedValues, polledRoute) => {
vi.useFakeTimers();
const workloadSpy = vi.spyOn(ChartsAPI, 'getWorkloadCharts').mockResolvedValue({
data: { [guest.id]: { cpu: point(11) } },
dockerData: {},
guestTypes: {},
timestamp: 2,
stats: { oldestDataTimestamp: 1 },
});
const infrastructureSpy = vi
.spyOn(ChartsAPI, 'getInfrastructureSummaryCharts')
.mockResolvedValue({
nodeData: { [node.id]: { cpu: point(22) } },
timestamp: 2,
stats: { oldestDataTimestamp: 1 },
});
const [polledSpy, idleSpy] =
polledRoute === 'workloads'
? [workloadSpy, infrastructureSpy]
: [infrastructureSpy, workloadSpy];
render(() => <SeriesProbe series={series} />);
await vi.advanceTimersByTimeAsync(0);
expect(screen.getByTestId('series-values')).toHaveTextContent(expectedValues);
expect(polledSpy).toHaveBeenCalledTimes(1);
expect(idleSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(WORKLOAD_TABLE_HISTORY_POLL_MS * 2);
expect(polledSpy).toHaveBeenCalledTimes(3);
expect(idleSpy).not.toHaveBeenCalled();
},
);
it('bounds slow-estate warming and prioritizes a distant active guest', async () => {
const guests = Array.from({ length: 500 }, (_, index) => ({
...guest,
@@ -37,7 +37,13 @@ interface WorkloadTableMetricHistoryOptions {
onDemand?: Accessor<boolean>;
prefetchGuests?: Accessor<readonly WorkloadGuest[]>;
range: Accessor<WorkloadTableMetricHistoryRange>;
selectedNode: Accessor<string | null | undefined>;
selectedNode?: Accessor<string | null | undefined>;
// The rows this caller draws while `enabled`. Each estate-wide history is a
// polled summary read, so a reader polls only the one its rows consume:
// guest rows read the workloads summary, node rows the infrastructure
// summary. Readers do not share in-flight requests, so a second reader on
// the same page pays for its own reads.
series: 'guests' | 'nodes';
}
interface ActiveGuestHistoryQueryKey {
@@ -111,13 +117,13 @@ const buildActiveGuestHistoryCacheKey = (key: ActiveGuestHistoryQueryKey): strin
export function useWorkloadTableMetricHistory(
options: WorkloadTableMetricHistoryOptions,
): WorkloadMetricHistoryReader {
const selectedNodeScope = createMemo(() => normalizeNodeScope(options.selectedNode()));
const selectedNodeScope = createMemo(() => normalizeNodeScope(options.selectedNode?.()));
const workloadHistoryScope = createMemo(() => {
if (!options.enabled()) return null;
if (options.series !== 'guests' || !options.enabled()) return null;
return buildHistoryScope(options.range(), selectedNodeScope());
});
const infrastructureHistoryScope = createMemo(() =>
options.enabled() ? buildHistoryScope(options.range()) : null,
options.series === 'nodes' && options.enabled() ? buildHistoryScope(options.range()) : null,
);
const workloadHistory = createNonSuspendingQuery<WorkloadChartsResponse, string>({
@@ -105,12 +105,6 @@ export interface WorkloadsSurfaceProps {
}>;
resourceSnapshotRefetch?: () => Promise<unknown>;
statusModeStorageScope?: string;
// Platform pages that render their own hosts table above the embedded
// workloads surface (e.g. Proxmox overview) own the per-host CPU / Memory
// / Disk / Temperature / uptime / version stats. Setting
// `compactGroupHeaders` strips those stats from the NodeGroupHeader rows
// in grouped mode so the section dividers don't duplicate the info.
compactGroupHeaders?: boolean;
// Default Workloads behavior owns grouped host row drawers inline. Platform
// pages with a dedicated host table can disable that drawer so host details
// open from the host-owned table instead of the embedded guest table.
@@ -581,6 +575,9 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
prefetchGuests: metricHistoryPrefetchGuests,
range: workloadMetricHistoryRange,
selectedNode,
// Grouped node rows carry no metric cells, so this reader never polls
// the infrastructure summary.
series: 'guests',
});
const [workloadHistoryHintSeen, setWorkloadHistoryHintSeen] = usePersistentSignal<boolean>(
STORAGE_KEYS.WORKLOADS_HISTORY_HINT_SEEN,
@@ -728,7 +725,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadsNoInventoryState,
ws,
groupingMode,
compactGroupHeaders: () => props.compactGroupHeaders === true,
groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',
groupLabelBadges,
} as const;
@@ -1,12 +1,8 @@
import { Component, For, Show, type JSX } from 'solid-js';
import { Component, Show, type JSX } from 'solid-js';
import type { Node } from '@/types/api';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator } from '@/utils/status';
import { formatUptime } from '@/utils/format';
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { formatProxmoxVersion } from '@/utils/proxmoxVersion';
import {
GROUPED_TABLE_ROW_BADGE_CLASS,
getGroupedTableRowCellClass,
@@ -17,13 +13,8 @@ import { ResourceNameWithWebInterfaceLink } from './WebInterfaceLink';
interface NodeGroupHeaderProps {
node: Node;
colspan?: number;
columns?: Array<{ id: string }>;
columnCellClass?: (columnId: string, isNameColumn: boolean) => string;
leadingAction?: JSX.Element;
renderColumnCell?: (columnId: string, node: Node) => JSX.Element;
renderAs?: 'tr' | 'div';
showFactsInName?: boolean;
temperatureThresholds?: MetricDisplayThresholds | null;
trClass?: string;
trProps?: JSX.HTMLAttributes<HTMLTableRowElement> &
Partial<Record<`data-${string}`, string | undefined>>;
@@ -35,12 +26,6 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
const nodeUrl = () => getNodeExternalUrl(props.node);
const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node);
const pveVersion = () => formatProxmoxVersion(props.node.pveVersion);
const cpuTemperature = () => getCpuTemperature(props.node.temperature);
const hasNodeFacts = () =>
Boolean(pveVersion()) ||
cpuTemperature() !== null ||
(typeof props.node.uptime === 'number' && props.node.uptime > 0);
const InnerContent = () => (
<div
@@ -84,25 +69,6 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
Agent
</span>
</Show>
<Show when={props.showFactsInName !== false && hasNodeFacts()}>
<span class="hidden sm:inline-flex flex-wrap items-center gap-3 text-[10px] font-medium text-muted">
<Show when={pveVersion()}>
<span title="Proxmox VE version">PVE {pveVersion()}</span>
</Show>
<Show when={cpuTemperature() !== null}>
<span
class={getTemperatureTextClass(cpuTemperature(), props.temperatureThresholds)}
title="CPU temperature"
>
{formatTemperature(cpuTemperature())}
</span>
</Show>
<Show when={typeof props.node.uptime === 'number' && props.node.uptime > 0}>
<span title="Node uptime">{formatUptime(props.node.uptime, true)}</span>
</Show>
</span>
</Show>
</div>
);
@@ -118,38 +84,9 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
}
>
<tr class={getGroupedTableRowClass(props.trClass)} {...props.trProps}>
<Show
when={(props.columns?.length ?? 0) > 0 && Boolean(props.renderColumnCell)}
fallback={
<td colspan={props.colspan} class={getGroupedTableRowCellClass()}>
<InnerContent />
</td>
}
>
<For each={props.columns}>
{(column) => {
const isNameColumn = () => column.id === 'name';
return (
<td
data-workload-col={column.id}
class={
props.columnCellClass?.(column.id, isNameColumn()) ??
(isNameColumn()
? getGroupedTableRowCellClass()
: 'px-1.5 sm:px-2 py-0.5 align-middle')
}
>
<Show
when={isNameColumn()}
fallback={props.renderColumnCell?.(column.id, props.node)}
>
<InnerContent />
</Show>
</td>
);
}}
</For>
</Show>
<td colspan={props.colspan} class={getGroupedTableRowCellClass()}>
<InnerContent />
</td>
</tr>
</Show>
);
@@ -216,6 +216,7 @@ import proxmoxCoverageTableSource from '@/features/proxmox/ProxmoxCoverageTable.
import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import proxmoxHostTableModelSource from '@/features/proxmox/proxmoxHostTableModel.ts?raw';
import proxmoxPageModelSource from '@/features/proxmox/proxmoxPageModel.ts?raw';
import proxmoxRecoverableTableSource from '@/features/proxmox/ProxmoxRecoverableTable.tsx?raw';
import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw';
import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw';
@@ -1406,9 +1407,9 @@ describe('shared primitive guardrails', () => {
});
it('routes Proxmox node version presentation through the shared formatter', () => {
expect(nodeGroupHeaderSource).toContain("from '@/utils/proxmoxVersion'");
expect(nodeGroupHeaderSource).toContain('formatProxmoxVersion(props.node.pveVersion)');
expect(nodeGroupHeaderSource).not.toContain('pve-manager\\/');
expect(proxmoxPageModelSource).toContain("from '@/utils/proxmoxVersion'");
expect(proxmoxPageModelSource).toContain('formatProxmoxVersion(resource.proxmox?.pveVersion)');
expect(proxmoxPageModelSource).not.toContain('pve-manager\\/');
expect(proxmoxVersionSource).toContain('formatProxmoxVersion');
expect(proxmoxVersionSource).toContain('pve-manager\\/');
@@ -1,9 +1,8 @@
import { render, screen } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import type { Node } from '@/types/api';
import { temperatureStore } from '@/utils/temperature';
const makeNode = (overrides: Partial<Node> = {}): Node => ({
id: 'cluster-a-pve1',
@@ -34,18 +33,11 @@ const makeNode = (overrides: Partial<Node> = {}): Node => ({
});
describe('NodeGroupHeader', () => {
beforeEach(() => {
temperatureStore.setUnit('celsius');
});
it('renders compact infrastructure facts in the grouped workload header', () => {
it('renders node identity in the grouped workload header', () => {
render(() => <NodeGroupHeader node={makeNode()} />);
expect(screen.getByText('pve1')).toBeInTheDocument();
expect(screen.getByText('homelab')).toBeInTheDocument();
expect(screen.getByText('PVE 9.1.9')).toBeInTheDocument();
expect(screen.getByText('62°C')).toBeInTheDocument();
expect(screen.getByText('2h')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Open web interface for pve1' });
expect(link).toHaveAttribute('href', 'https://pve1:8006');
@@ -53,20 +45,21 @@ describe('NodeGroupHeader', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('colors grouped header temperatures from configured thresholds', () => {
render(() => (
<NodeGroupHeader
node={makeNode({
temperature: {
available: true,
cpuPackage: 76,
lastUpdate: '2026-01-01T00:00:00Z',
},
})}
temperatureThresholds={{ warning: 80, critical: 85 }}
/>
it('spans the table in one cell and leaves host stats to the platform host table', () => {
const { container } = render(() => (
<table>
<tbody>
<NodeGroupHeader node={makeNode()} renderAs="tr" colspan={9} />
</tbody>
</table>
));
expect(screen.getByText('76°C')).toHaveClass('text-green-600');
const cells = container.querySelectorAll('tr > td');
expect(cells).toHaveLength(1);
expect(cells[0]).toHaveAttribute('colspan', '9');
expect(cells[0]).toHaveTextContent('pve1');
expect(screen.queryByText('PVE 9.1.9')).not.toBeInTheDocument();
expect(screen.queryByText(/°[CF]/)).not.toBeInTheDocument();
expect(screen.queryByText('2h')).not.toBeInTheDocument();
});
});
@@ -517,7 +517,11 @@ export const DockerHostsTable: Component<{
class={`${getPlatformTableCellClassForKind('numeric-value')} platform-table-narrow-hidden hidden text-base-content min-[360px]:table-cell md:table-cell`}
>
<PlatformTableTemperatureValue
value={host.temperature ?? docker()?.temperature}
value={
canRenderMetrics()
? (host.temperature ?? docker()?.temperature)
: undefined
}
/>
</TableCell>
<Show when={showSwarmColumn()}>
@@ -217,6 +217,58 @@ describe('DockerHostsTable', () => {
);
});
it("drops an offline host's retained temperature with its other readings", () => {
// A Docker host past its reporting lease is offline, and its last
// temperature is no more current than its last CPU, memory and disk.
const { container } = render(() => (
<DockerHostsTable
resources={[
makeDockerHost({
status: 'offline',
temperature: 51,
health: {
verdict: 'critical',
reasons: [
{ code: 'offline', detail: '' },
{ code: 'telemetry_stale', detail: '9m' },
],
},
}),
makeDockerHost({
id: 'agent:docker-02',
name: 'docker-02',
displayName: 'docker-02',
temperature: 51,
}),
]}
emptyIcon={<span />}
emptyTitle="No Docker hosts"
emptyDescription="No hosts"
showToolbar={false}
/>
));
const headers = Array.from(container.querySelectorAll('thead th')).map((th) =>
(th.textContent ?? '').trim(),
);
const tempIndex = headers.findIndex((header) => header.startsWith('Temp'));
expect(tempIndex).toBeGreaterThan(-1);
const temp = (id: string) =>
(
container.querySelectorAll(`[data-docker-host-row="${id}"] td`)[tempIndex]?.textContent ??
''
).trim();
expect(temp('agent:docker-01')).toBe('—');
expect(temp('agent:docker-02')).toBe('51.0°C');
const offlineRow = container.querySelector('[data-docker-host-row="agent:docker-01"]')!;
expect(within(offlineRow as HTMLElement).queryAllByTestId('stacked-memory-bar')).toHaveLength(
0,
);
expect(offlineRow.querySelector('[data-docker-host-stale] .sr-only')).toHaveTextContent(
'No report for 9m',
);
});
it('tints a host row for the Docker alerts its drawer lists, not by display name', () => {
// Docker alerts are keyed on the Docker host id and carry the hostname
// as their node; neither is the row's id or its display name.
@@ -258,12 +258,13 @@ export const ProxmoxNodesTable: Component<{
nodePreview.toggle();
};
// Use the same canonical history reader the workloads table uses; cache
// keys collide so the two readers dedupe their fetches.
// Use the same canonical history reader the workloads table uses. This
// table draws node rows only, so it polls the infrastructure summary and
// leaves the guest history to the workloads table below.
const metricHistory = useWorkloadTableMetricHistory({
enabled: isSparklineMode,
range: () => props.metricHistoryRange?.() ?? '1h',
selectedNode: () => '',
series: 'nodes',
});
return (
@@ -495,7 +495,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
// surface, so label it with the platform vocabulary instead of 'Info'.
columnLabelOverrides: { info: 'ID' },
statusModeStorageScope: PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE,
compactGroupHeaders: true,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
onMetricDisplayModeChange: props.setMetricDisplayMode,
@@ -638,7 +637,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
forcedPlatform={PROXMOX_PLATFORM_FILTER}
excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}
showNestedExcludedWorkloads
compactGroupHeaders
groupNodeDrawerMode="disabled"
suppressFilterToolbar
emptyStateTitle="No Proxmox workloads"
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -9,6 +10,7 @@ const nodeDrawerMock = vi.hoisted(() => vi.fn());
const activeAlertsMock = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
const getMetricThresholdsMock = vi.hoisted(() => vi.fn());
const temperatureGaugeMock = vi.hoisted(() => vi.fn());
const metricHistoryMock = vi.hoisted(() => vi.fn());
vi.mock('@/hooks/useBreakpoint', () => ({
useBreakpoint: () => ({
@@ -63,9 +65,12 @@ vi.mock('@/components/shared/TemperatureGauge', () => ({
}));
vi.mock('@/components/Workloads/useWorkloadTableMetricHistory', () => ({
useWorkloadTableMetricHistory: () => ({
getNodeMetricSeries: () => [],
}),
useWorkloadTableMetricHistory: (options: unknown) => {
metricHistoryMock(options);
return {
getNodeMetricSeries: () => [],
};
},
}));
vi.mock('@/components/Workloads/NodeDrawer', () => ({
@@ -578,6 +583,34 @@ describe('ProxmoxNodesTable', () => {
expect(screen.queryByTestId('temperature-gauge')).not.toBeInTheDocument();
});
it('reads node history only, and only while Trends is on', () => {
const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars');
render(() => (
<ProxmoxNodesTable
nodes={[makeNodeResource()]}
guests={[]}
metricDisplayMode={mode}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
expect(metricHistoryMock).toHaveBeenCalledTimes(1);
const options = metricHistoryMock.mock.calls[0][0] as {
enabled: () => boolean;
selectedNode?: unknown;
series: string;
};
// Guest history belongs to the workloads table below; a second guest
// reader here would poll the workloads route for rows it never draws.
expect(options.series).toBe('nodes');
expect(options.selectedNode).toBeUndefined();
expect(options.enabled()).toBe(false);
setMode('sparklines');
expect(options.enabled()).toBe(true);
});
it('updates every availability signal when a live row becomes offline', () => {
const [state, setState] = createStore({
nodes: [makeNodeResource({ uptime: 187_200, temperature: 76 })],
@@ -214,6 +214,16 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('leaves host details to the nodes table instead of the embedded guest table', () => {
setResources([makeResource({ id: 'vm-1', type: 'vm' })]);
renderSurface();
expect(mockWorkloadsOptions).toHaveBeenCalledWith(
expect.objectContaining({ groupNodeDrawerMode: 'disabled' }),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -57,6 +57,7 @@ import {
type PlatformResourceStatusFilter,
withPlatformStatusCounts,
} from '@/features/platformPage/sharedPlatformPage';
import { PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS } from '@/features/storageBackups/diskTemperaturePresentation';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import type { Disk } from '@/types/api';
import type { Resource, ResourceAvailabilityMeta } from '@/types/resource';
@@ -76,6 +77,7 @@ import {
import { asTrimmedString } from '@/utils/stringUtils';
import { getPreferredResourceIP } from '@/utils/resourceIdentity';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import { formatTemperature } from '@/utils/temperature';
import {
RESOURCE_METADATA_CHANGED_EVENT,
type ResourceMetadataChangedDetail,
@@ -101,6 +103,7 @@ import {
getAgentMachineTemperatureTitle,
getAgentMachineThermalPressurePresentation,
getNextAgentMachineSortState,
isAgentMachineTemperatureLastKnown,
sortAgentMachines,
type AgentMachineColumn,
type AgentMachineColumnId,
@@ -183,6 +186,8 @@ const AgentMachineMetricTooltip: Component<{
const AgentMachineTemperatureCell: Component<{
celsius: number | undefined;
/** The value is a retained reading, not a current one. */
lastKnown?: boolean;
metric: AgentMachineTemperatureMetric;
sections: AgentMachineTemperatureDetailSection[];
thresholds?: MetricDisplayThresholds | null;
@@ -222,7 +227,26 @@ const AgentMachineTemperatureCell: Component<{
}
>
{(value) => (
<TemperatureGauge value={value()} metric={props.metric} thresholds={props.thresholds} />
<Show
when={!props.lastKnown}
fallback={
// A retained reading takes no threshold colour; its detail
// rows say it is last known.
<span
class={`text-xs whitespace-nowrap ${PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS}`}
data-temperature-reading="last-known"
>
{formatTemperature(value())}
<span class="sr-only">, last known</span>
</span>
}
>
<TemperatureGauge
value={value()}
metric={props.metric}
thresholds={props.thresholds}
/>
</Show>
)}
</Show>
}
@@ -1565,6 +1589,7 @@ export const AgentsMachinesTable: Component<{
const raidArrays = () => getAgentMachineRaidArrayDetails(machine);
const raidSummary = () => getAgentMachineRaidSummary(machine);
const temperature = () => getAgentMachineTemperatureCelsius(machine);
const temperatureLastKnown = () => isAgentMachineTemperatureLastKnown(machine);
const temperatureMetric = () => getAgentMachineTemperatureMetric(machine);
const temperatureThresholds = () =>
temperatureMetric() === 'diskTemperature'
@@ -1781,7 +1806,9 @@ export const AgentsMachinesTable: Component<{
class={`${getPlatformTableCellClassForKind('numeric-value')} ${machineColumnWidthClass('uptime')} text-base-content`}
>
{formatPlatformTableUptimeValue(
machine.uptime ?? machine.agent?.uptimeSeconds,
canRenderMetrics()
? (machine.uptime ?? machine.agent?.uptimeSeconds)
: undefined,
)}
</TableCell>
</Show>
@@ -1789,14 +1816,20 @@ export const AgentsMachinesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} ${machineColumnWidthClass('temp')} text-base-content`}
>
<AgentMachineTemperatureCell
celsius={temperature()}
metric={temperatureMetric()}
sections={temperatureSections()}
thresholds={temperatureThresholds()}
title={temperatureTitle()}
thermalPressure={thermalPressure()}
/>
<Show
when={canRenderMetrics()}
fallback={<span class="text-muted">—</span>}
>
<AgentMachineTemperatureCell
celsius={temperature()}
lastKnown={temperatureLastKnown()}
metric={temperatureMetric()}
sections={temperatureSections()}
thresholds={temperatureThresholds()}
title={temperatureTitle()}
thermalPressure={thermalPressure()}
/>
</Show>
</TableCell>
</Show>
<Show when={columnVisibility.isColumnVisible('lastSeen')}>
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentMetadataAPI } from '@/api/agentMetadata';
@@ -1159,6 +1160,238 @@ describe('AgentsMachinesTable', () => {
expect(screen.getByText('1400 RPM')).toBeInTheDocument();
});
it('shows a retained disk temperature as last known, never as a current reading', async () => {
// A reporting agent whose SMART probe returned no temperature keeps the
// last reading with a non-available collection state. (A host agent past
// its lease is offline, and its row shows no temperature at all.)
const stoppedReporting = {
temperature: {
state: 'unavailable' as const,
source: 'smartctl',
reason: 'SMART probe returned no usable temperature data',
},
};
const { container } = render(() => (
<AgentsMachinesTable
resources={[
resource({
id: 'silent-nas',
name: 'Silent NAS',
agent: {
sensors: {
smart: [
{ device: '/dev/sda', model: 'Archive HDD', temperature: 71 },
{ device: '/dev/sdb', model: 'Parity HDD', temperature: 66 },
].map((disk) => ({ ...disk, collection: stoppedReporting })),
},
},
}),
resource({
id: 'mixed-host',
name: 'Mixed Host',
agent: {
sensors: {
smart: [
{
device: '/dev/sda',
model: 'Retained HDD',
temperature: 69,
collection: stoppedReporting,
},
{ device: '/dev/nvme0', model: 'Fast SSD', temperature: 44 },
],
},
},
}),
]}
emptyIcon={emptyIcon}
emptyTitle="No machines"
emptyDescription="Install Pulse Agent."
/>
));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Temp'));
const triggerFor = (id: string) =>
container.querySelector(
`[data-agents-machine-row="${id}"] [data-agent-machine-temperature-trigger="true"]`,
) as HTMLElement | null;
// Nothing is collected now, so the cell keeps the hottest retained value
// without a threshold colour and says it is last known.
const silent = triggerFor('silent-nas');
expect(silent).not.toBeNull();
if (!silent) return;
const retained = silent.querySelector('[data-temperature-reading="last-known"]');
expect(retained).not.toBeNull();
expect(retained).toHaveTextContent('71°C, last known');
expect(retained).toHaveClass('underline', 'decoration-dotted', 'text-muted');
expect(retained?.className).not.toMatch(/text-(green|yellow|red)-/);
await fireEvent.mouseEnter(silent);
const tooltip = await waitFor(() => {
const element = document.querySelector('[data-agent-machine-temperature-tooltip="true"]');
expect(element).not.toBeNull();
return element as HTMLElement;
});
expect(within(tooltip).getByText('71°C (last known)')).toHaveClass('text-muted');
expect(within(tooltip).getByText('66°C (last known)')).toHaveClass('text-muted');
await fireEvent.mouseLeave(silent);
// A disk collected now leads the cell even when a retained one is hotter.
const mixed = triggerFor('mixed-host');
expect(mixed).not.toBeNull();
if (!mixed) return;
expect(mixed).toHaveTextContent('44°C');
expect(mixed.querySelector('[data-temperature-reading="last-known"]')).toBeNull();
expect(within(mixed).queryByText('69°C')).toBeNull();
});
it("blanks an offline machine's retained readings, temperature and uptime included", async () => {
// A host agent past its reporting lease is offline: CPU, memory, disk and
// the sensors, uptime and disks it last reported are not current.
const stoppedReporting = {
temperature: {
state: 'unavailable' as const,
source: 'smartctl',
reason: 'host agent stopped reporting',
},
};
const lastReport: Partial<Resource> = {
cpu: { current: 32 },
memory: { total: 100, used: 73, free: 27, current: 73 },
disk: { total: 100, used: 80, free: 20, current: 80 },
};
const sensors = {
temperatureCelsius: { 'cpu.package': 45 },
thermalState: { pressure: 'serious' },
smart: [
{
device: '/dev/sda',
model: 'Archive HDD',
temperature: 41,
collection: stoppedReporting,
},
],
};
const { container } = render(() => (
<AgentsMachinesTable
resources={[
resource({
id: 'silent-host',
name: 'Silent Host',
status: 'offline',
uptime: 2_500_000,
...lastReport,
agent: { stale: true, uptimeSeconds: 2_500_000, sensors },
}),
resource({
id: 'live-host',
name: 'Live Host',
uptime: 2_500_000,
...lastReport,
agent: {
uptimeSeconds: 2_500_000,
sensors: { temperatureCelsius: { 'cpu.package': 45 } },
},
}),
]}
emptyIcon={emptyIcon}
emptyTitle="No machines"
emptyDescription="Install Pulse Agent."
/>
));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Temp'));
await fireEvent.click(screen.getByLabelText('Uptime'));
const cells = (id: string) => {
const row = container.querySelector(`[data-agents-machine-row="${id}"]`) as HTMLElement;
const headers = Array.from(container.querySelectorAll('thead th')).map((th) =>
(th.textContent ?? '').replace(/[▲▼]/g, '').trim(),
);
const values = Array.from(row.querySelectorAll('td')).map((td) =>
(td.textContent ?? '').trim(),
);
return (label: string) => values[headers.indexOf(label)];
};
const silent = cells('silent-host');
expect(silent('CPU')).toBe('—');
expect(silent('Up')).toBe('—');
expect(silent('Temp')).toBe('—');
expect(
container.querySelector(
'[data-agents-machine-row="silent-host"] [data-agent-machine-temperature-trigger="true"]',
),
).toBeNull();
const live = cells('live-host');
expect(live('CPU')).not.toBe('—');
expect(live('Up')).toBe('28d');
expect(live('Temp')).toBe('45°C');
});
it('moves a machine temperature between current and last known as its disk reports change', async () => {
// Fresh objects per update, like parsed API payloads: the store reconciles
// into the objects it was given, so a reused fixture would be rewritten.
const collected = () => ({ temperature: { state: 'available' as const, source: 'smartctl' } });
const stoppedReporting = () => ({
temperature: {
state: 'unavailable' as const,
source: 'smartctl',
reason: 'host agent stopped reporting',
},
});
const nas = (
temperature: number,
collection?: ReturnType<typeof collected> | ReturnType<typeof stoppedReporting>,
) =>
resource({
id: 'nas',
name: 'NAS',
agent: {
sensors: { smart: [{ device: '/dev/sda', type: 'sata', temperature, collection }] },
},
});
const [resources, setResources] = createSignal<Resource[]>([nas(52, collected())]);
const { container } = render(() => (
<AgentsMachinesTable
resources={resources()}
emptyIcon={emptyIcon}
emptyTitle="No machines"
emptyDescription="Install Pulse Agent."
/>
));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Temp'));
const cell = () =>
container.querySelector(
'[data-agents-machine-row="nas"] [data-agent-machine-temperature-trigger="true"]',
) as HTMLElement;
const lastKnown = () => cell().querySelector('[data-temperature-reading="last-known"]');
expect(cell()).toHaveTextContent('52°C');
expect(lastKnown()).toBeNull();
setResources([nas(52, stoppedReporting())]);
await waitFor(() => expect(lastKnown()).not.toBeNull());
expect(cell()).toHaveTextContent('52°C, last known');
setResources([nas(47, collected())]);
await waitFor(() => expect(lastKnown()).toBeNull());
expect(cell()).toHaveTextContent('47°C');
expect(cell().querySelector('.decoration-dotted')).toBeNull();
expect(cell()).not.toHaveTextContent('last known');
setResources([nas(0)]);
await waitFor(() => expect(cell()).toHaveTextContent('—'));
expect(lastKnown()).toBeNull();
});
it('shows thermal pressure when macOS reports no Celsius temperature', async () => {
render(() => (
<AgentsMachinesTable
@@ -6,11 +6,14 @@ import {
getAgentMachineDiskIODetails,
getAgentMachineGPUTitle,
getAgentMachineGPUUtilizationPercent,
getAgentMachineHottestSmartDiskType,
getAgentMachineNetworkInterfaceDetails,
getAgentMachineRaidArrayDetails,
getAgentMachineTemperatureCelsius,
getAgentMachineTemperatureDetailSections,
getAgentMachineTemperatureMetric,
getAgentMachineTemperatureTitle,
isAgentMachineTemperatureLastKnown,
matchesAgentMachineSearch,
sortAgentMachines,
} from '../agentMachineTableModel';
@@ -227,6 +230,167 @@ describe('agentMachineTableModel', () => {
expect(getAgentMachineTemperatureMetric(machine)).toBe('diskTemperature');
});
it('shows a retained SMART temperature only as last known', () => {
// A host agent past its reporting lease keeps its last SMART temperature
// with a non-available collection state.
const stoppedReporting = {
temperature: {
state: 'unavailable' as const,
source: 'host_agent',
reason: 'host agent stopped reporting',
},
};
const mixed = resource({
agent: {
sensors: {
smart: [
{
device: '/dev/sda',
model: 'Retained HDD',
type: 'sas',
temperature: 71,
collection: stoppedReporting,
},
{
device: '/dev/nvme0',
model: 'Fast SSD',
type: 'nvme',
temperature: 44,
collection: { temperature: { state: 'available' as const, source: 'smartctl' } },
},
// Agents that predate collection state report no provenance.
{ device: '/dev/sdb', temperature: 39 },
{ device: '/dev/sdc', model: 'Cold Standby', temperature: 0, standby: true },
],
},
},
});
// A disk collected now outranks a hotter retained one.
expect(getAgentMachineTemperatureCelsius(mixed)).toBe(44);
expect(isAgentMachineTemperatureLastKnown(mixed)).toBe(false);
expect(getAgentMachineTemperatureMetric(mixed)).toBe('diskTemperature');
expect(getAgentMachineHottestSmartDiskType(mixed)).toBe('nvme');
expect(getAgentMachineTemperatureTitle(mixed).split('\n')).toEqual([
'Disk Temperatures',
'Disk /dev/nvme0 Fast SSD: 44°C',
'Disk /dev/sdb: 39°C',
'Disk /dev/sda Retained HDD: 71°C (last known)',
'Disk /dev/sdc Cold Standby: standby',
]);
expect(getAgentMachineTemperatureDetailSections(mixed)[0].rows[2]).toEqual({
label: 'Disk /dev/sda Retained HDD',
value: '71°C (last known)',
muted: true,
});
// With nothing collected now, the cell keeps the hottest retained value
// and marks it last known.
const onlyRetained = resource({
agent: {
sensors: {
smart: [
{ device: '/dev/sda', type: 'sata', temperature: 71, collection: stoppedReporting },
{ device: '/dev/nvme0', type: 'nvme', temperature: 58, collection: stoppedReporting },
],
},
},
});
expect(getAgentMachineTemperatureCelsius(onlyRetained)).toBe(71);
expect(isAgentMachineTemperatureLastKnown(onlyRetained)).toBe(true);
expect(getAgentMachineTemperatureMetric(onlyRetained)).toBe('diskTemperature');
expect(getAgentMachineHottestSmartDiskType(onlyRetained)).toBe('sata');
expect(getAgentMachineTemperatureTitle(onlyRetained)).toBe(
'Disk Temperatures\nDisk /dev/sda: 71°C (last known)\nDisk /dev/nvme0: 58°C (last known)',
);
// CPU sensors carry no collection state, so they still lead the cell.
const withSensors = resource({
agent: {
sensors: {
temperatureCelsius: { 'cpu.package': 49 },
smart: [{ device: '/dev/sda', temperature: 71, collection: stoppedReporting }],
},
},
});
expect(getAgentMachineTemperatureCelsius(withSensors)).toBe(49);
expect(isAgentMachineTemperatureLastKnown(withSensors)).toBe(false);
expect(getAgentMachineTemperatureMetric(withSensors)).toBe('temperature');
});
it('offers only positive, non-standby SMART readings to the cell, behind direct and CPU sensors', () => {
const stoppedReporting = {
temperature: {
state: 'unavailable' as const,
source: 'smartctl',
reason: 'host agent stopped reporting',
},
};
const retainedDisk = (temperature: number) => ({
device: '/dev/sda',
temperature,
collection: stoppedReporting,
});
// A disk in standby is never a temperature candidate, retained or not.
const standbyOnly = resource({
agent: { sensors: { smart: [{ ...retainedDisk(52), standby: true }] } },
});
expect(getAgentMachineTemperatureCelsius(standbyOnly)).toBeUndefined();
expect(isAgentMachineTemperatureLastKnown(standbyOnly)).toBe(false);
expect(getAgentMachineTemperatureTitle(standbyOnly)).toBe(
'Disk Temperatures\nDisk /dev/sda: standby',
);
// A direct machine temperature leads a retained disk.
const direct = resource({
temperature: 47,
agent: { sensors: { smart: [retainedDisk(71)] } },
});
expect(getAgentMachineTemperatureCelsius(direct)).toBe(47);
expect(isAgentMachineTemperatureLastKnown(direct)).toBe(false);
expect(getAgentMachineTemperatureMetric(direct)).toBe('temperature');
// Additional sensors never lead the cell, so the retained disk is shown.
const additionalOnly = resource({
agent: { sensors: { additional: { vrm: 49 }, smart: [retainedDisk(71)] } },
});
expect(getAgentMachineTemperatureCelsius(additionalOnly)).toBe(71);
expect(isAgentMachineTemperatureLastKnown(additionalOnly)).toBe(true);
// Sorting follows the value the cell shows: a hotter retained disk does not
// lift a machine whose shown reading is a cooler current one.
const machines = [
resource({
id: 'retained',
name: 'Retained',
agent: { sensors: { smart: [retainedDisk(71)] } },
}),
resource({
id: 'mixed',
name: 'Mixed',
agent: {
sensors: { smart: [retainedDisk(80), { device: '/dev/sdb', temperature: 35 }] },
},
}),
resource({
id: 'current',
name: 'Current',
agent: { sensors: { smart: [{ device: '/dev/sda', temperature: 50 }] } },
}),
];
const sortedIds = (direction: 'asc' | 'desc') =>
sortAgentMachines(
machines,
'temp',
direction,
() => '',
() => '',
).map((machine) => machine.id);
expect(sortedIds('desc')).toEqual(['retained', 'current', 'mixed']);
expect(sortedIds('asc')).toEqual(['mixed', 'current', 'retained']);
});
it('prefers direct and sensor temperatures over SMART fallback temperatures', () => {
const direct = resource({
temperature: 55,
@@ -2,6 +2,7 @@ import type { ColumnDef } from '@/hooks/useColumnVisibility';
import type { HostDiskIO, HostRAIDArray, HostRAIDDevice } from '@/types/api';
import type { Resource } from '@/types/resource';
import { getPlatformTableFiniteMetric } from '@/features/platformPage/sharedPlatformPage';
import { isPhysicalDiskTemperatureCurrent } from '@/features/storageBackups/diskTemperaturePresentation';
import { formatBytes, normalizeDiskArray } from '@/utils/format';
import { asTrimmedString } from '@/utils/stringUtils';
@@ -155,6 +156,9 @@ type TemperatureReading = {
type SmartTemperatureReading = TemperatureReading & {
standby?: boolean;
diskType?: string;
// False for a retained value, such as the last temperature of a host agent
// that stopped reporting.
current: boolean;
};
export type AgentMachineTemperatureDetailRow = {
@@ -257,13 +261,21 @@ const getSmartTemperatureReadings = (machine: Resource): SmartTemperatureReading
value: temperature ?? 0,
standby: disk.standby,
diskType: asTrimmedString(disk.type),
current: isPhysicalDiskTemperatureCurrent(disk.collection),
});
return readings;
}, []);
const getActiveSmartTemperatureReadings = (machine: Resource): TemperatureReading[] =>
// Only a temperature collected now can stand for the machine or pick its
// threshold. A retained one stays in the detail rows, marked last known.
const getActiveSmartTemperatureReadings = (machine: Resource): SmartTemperatureReading[] =>
getSmartTemperatureReadings(machine).filter(
(reading): reading is TemperatureReading => !reading.standby && reading.value > 0,
(reading) => !reading.standby && reading.value > 0 && reading.current,
);
const getLastKnownSmartTemperatureReadings = (machine: Resource): SmartTemperatureReading[] =>
getSmartTemperatureReadings(machine).filter(
(reading) => !reading.standby && reading.value > 0 && !reading.current,
);
const byHighestTemperature = (left: TemperatureReading, right: TemperatureReading): number =>
@@ -536,22 +548,38 @@ export const getAgentMachineDiskIODetails = (machine: Resource): AgentMachineDis
}, []);
};
const hasMachineSensorTemperature = (machine: Resource): boolean =>
positiveTemperature(machine.temperature) !== undefined ||
getSensorTemperatureReadings(machine).length > 0;
// The SMART readings the cell falls back to: the ones collected now, or the
// retained ones when no disk has a current reading.
const getCellSmartTemperatureReadings = (machine: Resource): SmartTemperatureReading[] => {
const active = getActiveSmartTemperatureReadings(machine);
return active.length > 0 ? active : getLastKnownSmartTemperatureReadings(machine);
};
export const getAgentMachineTemperatureCelsius = (machine: Resource): number | undefined => {
const direct = positiveTemperature(machine.temperature);
if (direct !== undefined) return direct;
return (
maxTemperatureReading(getSensorTemperatureReadings(machine)) ??
maxTemperatureReading(getActiveSmartTemperatureReadings(machine))
maxTemperatureReading(getCellSmartTemperatureReadings(machine))
);
};
// Disk type of the hottest active SMART reading — the disk whose temperature
// the cell displays when it falls back to the diskTemperature metric.
// True when the cell's value is a retained SMART reading, such as the last
// disk temperature of a host agent that stopped reporting.
export const isAgentMachineTemperatureLastKnown = (machine: Resource): boolean =>
!hasMachineSensorTemperature(machine) &&
getActiveSmartTemperatureReadings(machine).length === 0 &&
getLastKnownSmartTemperatureReadings(machine).length > 0;
// Disk type of the hottest SMART reading the cell displays when it falls back
// to the diskTemperature metric.
export const getAgentMachineHottestSmartDiskType = (machine: Resource): string | undefined => {
const readings = getSmartTemperatureReadings(machine).filter(
(reading) => !reading.standby && reading.value > 0,
);
const readings = getCellSmartTemperatureReadings(machine);
if (readings.length === 0) return undefined;
return readings.reduce((worst, reading) => (reading.value > worst.value ? reading : worst))
.diskType;
@@ -560,13 +588,8 @@ export const getAgentMachineHottestSmartDiskType = (machine: Resource): string |
export const getAgentMachineTemperatureMetric = (
machine: Resource,
): AgentMachineTemperatureMetric => {
if (positiveTemperature(machine.temperature) !== undefined) return 'temperature';
if (maxTemperatureReading(getSensorTemperatureReadings(machine)) !== undefined) {
return 'temperature';
}
if (maxTemperatureReading(getActiveSmartTemperatureReadings(machine)) !== undefined) {
return 'diskTemperature';
}
if (hasMachineSensorTemperature(machine)) return 'temperature';
if (getCellSmartTemperatureReadings(machine).length > 0) return 'diskTemperature';
return 'temperature';
};
@@ -585,6 +608,13 @@ export const getAgentMachineTemperatureDetailSections = (
label: `Disk ${reading.label}`,
value: formatTemperatureValue(reading),
}));
const lastKnownSmartReadings = getLastKnownSmartTemperatureReadings(machine)
.sort(byHighestTemperature)
.map((reading) => ({
label: `Disk ${reading.label}`,
value: `${formatTemperatureValue(reading)} (last known)`,
muted: true,
}));
const standbySmartReadings = getSmartTemperatureReadings(machine)
.filter((reading) => reading.standby)
.sort(byLabel)
@@ -609,7 +639,11 @@ export const getAgentMachineTemperatureDetailSections = (
...section('Temperatures', capTemperatureRows(sensorReadings)),
...section(
'Disk Temperatures',
capTemperatureRows([...activeSmartReadings, ...standbySmartReadings]),
capTemperatureRows([
...activeSmartReadings,
...lastKnownSmartReadings,
...standbySmartReadings,
]),
),
...section('Fan Speeds', capTemperatureRows(fanReadings)),
...section('Other Sensors', capTemperatureRows(additionalReadings)),
@@ -69,6 +69,8 @@ describe('diskDetailPresentation', () => {
expect(getLinkedDiskTemperatureTextClass(65)).toBe('text-red-500');
expect(getLinkedDiskTemperatureTextClass(52)).toBe('text-yellow-500');
expect(getLinkedDiskTemperatureTextClass(45)).toBe('text-muted');
// Disk temperature alerting off for the disk: no reading is warm or hot.
expect(getLinkedDiskTemperatureTextClass(80, null)).toBe('text-muted');
// Explicit thresholds (e.g. resolved for an NVMe disk) shift the colors.
expect(getLinkedDiskTemperatureTextClass(55, { warning: 65, critical: 70 })).toBe('text-muted');
expect(getLinkedDiskTemperatureTextClass(67, { warning: 65, critical: 70 })).toBe(
@@ -18,6 +18,7 @@ import {
PHYSICAL_DISK_SOURCE_BADGE_CLASS,
PHYSICAL_DISK_TABLE_CLASS,
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS,
getPhysicalDiskEmptyStatePresentation,
getPhysicalDiskCellPaddingClass,
getPhysicalDiskColumnWidthStyle,
@@ -27,6 +28,7 @@ import {
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
getPhysicalDiskLastKnownTemperatureTitle,
getPhysicalDiskLifeLabel,
getPhysicalDiskLifeTextClass,
getPhysicalDiskNormalizedHealth,
@@ -38,6 +40,7 @@ import {
getPhysicalDiskSourceBadgePresentation,
getPhysicalDiskTableLayoutModeForContainer,
getPhysicalDiskTemperaturePresentation,
getPhysicalDiskTemperatureThresholds,
hasUnraidPhysicalDiskFaultSignal,
hasPhysicalDiskSmartWarning,
isPhysicalDiskWearoutReported,
@@ -48,6 +51,12 @@ import {
normalizePhysicalDiskFacetFilter,
type PhysicalDiskPresentationData,
} from '@/features/storageBackups/diskPresentation';
import * as diskTemperaturePresentation from '@/features/storageBackups/diskTemperaturePresentation';
import {
resolveDiskTemperatureDisplayThresholds,
type MetricDisplayThresholds,
} from '@/utils/metricThresholds';
import { getTemperatureTextClass } from '@/utils/temperature';
function makeDiskData(
overrides: Partial<PhysicalDiskPresentationData> = {},
@@ -131,6 +140,23 @@ describe('diskPresentation', () => {
}
});
it('serves the same temperature decision the Machines table and drawers import directly', () => {
// Tables outside Storage import the small module so they do not pull this
// one into their chunks. Both paths must stay one implementation.
expect(isPhysicalDiskTemperatureCurrent).toBe(
diskTemperaturePresentation.isPhysicalDiskTemperatureCurrent,
);
expect(getPhysicalDiskTemperaturePresentation).toBe(
diskTemperaturePresentation.getPhysicalDiskTemperaturePresentation,
);
expect(getPhysicalDiskLastKnownTemperatureTitle).toBe(
diskTemperaturePresentation.getPhysicalDiskLastKnownTemperatureTitle,
);
expect(PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS).toBe(
diskTemperaturePresentation.PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS,
);
});
it('distinguishes unsupported, unavailable, and unexpectedly missing disk evidence', () => {
expect(
getPhysicalDiskFieldStatusMessage('Disk I/O', {
@@ -268,10 +294,313 @@ describe('diskPresentation', () => {
it('shortens only the health words that cannot fit a phone health column', () => {
expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention');
expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace');
expect(getPhysicalDiskHealthCompactLabel('Running Hot')).toBe('Hot');
expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy');
expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown');
});
describe('heat judged by the alert disk temperature policy', () => {
// Disk risk never carries heat. The verdict judges a current reading
// against the same per-type alert thresholds the Temp cell colours by.
const buildDisk = (id: string, overrides: Record<string, unknown> = {}): Resource =>
({
id,
name: id,
type: 'physical_disk',
status: 'online',
physicalDisk: {
devPath: `/dev/${id}`,
model: 'Crucial MX500 2TB',
diskType: 'sata',
health: 'PASSED',
wearout: 95,
temperature: 40,
collection: { temperature: { state: 'available', source: 'smartctl' } },
...overrides,
},
identity: { hostname: 'pve3' },
canonicalIdentity: { hostname: 'pve3' },
platformType: 'proxmox-pve',
}) as unknown as Resource;
const verdictOf = (
disk: Resource,
resolve?: (diskType: string) => MetricDisplayThresholds | null,
) => getPhysicalDiskHealthStatus(extractPhysicalDiskPresentationData(disk, resolve));
// A user who raised the NVMe trigger to 75C in Alerts; warning colours
// start at its 70C clear value.
const raisedNVMe = (diskType: string) =>
diskType === 'nvme'
? { warning: 70, critical: 75 }
: resolveDiskTemperatureDisplayThresholds(null, diskType);
it('calls a disk Running Hot from its type alert trigger, not a flat line', () => {
expect(verdictOf(buildDisk('nvme0n1', { diskType: 'nvme', temperature: 63 })).label).toBe(
'Healthy',
);
expect(verdictOf(buildDisk('nvme0n1', { diskType: 'nvme', temperature: 72 }))).toEqual({
label: 'Running Hot',
summary: 'Disk temperature is 72°C, at or above its 70°C alert threshold.',
tone: 'text-red-700 dark:text-red-300',
});
expect(verdictOf(buildDisk('sda', { temperature: 56 }))).toEqual({
label: 'Running Hot',
summary: 'Disk temperature is 56°C, at or above its 55°C alert threshold.',
tone: 'text-red-700 dark:text-red-300',
});
// The amber band below the trigger is a Temp cell colour, not a verdict.
expect(verdictOf(buildDisk('sdb', { temperature: 52 })).label).toBe('Healthy');
const hotDisk = buildDisk('sdc', { temperature: 56 });
expect(
getPhysicalDiskNormalizedHealth(hotDisk, extractPhysicalDiskPresentationData(hotDisk)),
).toBe('critical');
});
it('follows a raised trigger from the alert configuration', () => {
const nvme72 = buildDisk('nvme0n1', { diskType: 'nvme', temperature: 72 });
expect(verdictOf(nvme72, raisedNVMe).label).toBe('Healthy');
expect(
verdictOf(buildDisk('nvme1n1', { diskType: 'nvme', temperature: 76 }), raisedNVMe),
).toEqual({
label: 'Running Hot',
summary: 'Disk temperature is 76°C, at or above its 75°C alert threshold.',
tone: 'text-red-700 dark:text-red-300',
});
expect(verdictOf(buildDisk('sda', { temperature: 56 }), raisedNVMe).label).toBe(
'Running Hot',
);
expect(extractPhysicalDiskPresentationData(nvme72, raisedNVMe).temperatureThresholds).toEqual(
{ warning: 70, critical: 75 },
);
});
it('reads red in the Temp cell exactly when the verdict says Running Hot', () => {
const red = getTemperatureTextClass(99, { warning: 1, critical: 2 }, 'diskTemperature');
// A user who switched disk temperature alerting off resolves to null.
const alertingOff = () => null;
for (const resolve of [undefined, raisedNVMe, alertingOff]) {
for (const diskType of ['nvme', 'sata', 'sas', 'hdd', '']) {
for (let celsius = 40; celsius <= 80; celsius += 1) {
const data = extractPhysicalDiskPresentationData(
buildDisk('sda', { diskType, temperature: celsius }),
resolve,
);
const cellIsRed =
getTemperatureTextClass(
celsius,
getPhysicalDiskTemperatureThresholds(data),
'diskTemperature',
) === red;
expect([
diskType,
celsius,
getPhysicalDiskHealthStatus(data).label === 'Running Hot',
]).toEqual([diskType, celsius, cellIsRed]);
}
}
}
});
it('calls no disk hot when its temperature alerting is off', () => {
const off = () => null;
const data = extractPhysicalDiskPresentationData(buildDisk('sda', { temperature: 80 }), off);
expect(data.temperatureThresholds).toBeNull();
expect(getPhysicalDiskHealthStatus(data).label).toBe('Healthy');
expect(
getTemperatureTextClass(80, getPhysicalDiskTemperatureThresholds(data), 'diskTemperature'),
).toBe('text-base-content');
});
it('never calls a retained reading hot', () => {
const silent = buildDisk('sda', {
temperature: 80,
collection: {
temperature: {
state: 'unavailable',
source: 'host_agent',
reason: 'host agent stopped reporting',
},
},
});
expect(verdictOf(silent).label).toBe('Healthy');
// A source that predates collection state is current.
expect(verdictOf(buildDisk('sdb', { temperature: 80, collection: undefined })).label).toBe(
'Running Hot',
);
});
it('keeps Replace Now for failure evidence on a hot disk', () => {
const pending = verdictOf(
buildDisk('sda', {
temperature: 72,
risk: {
level: 'critical',
reasons: [
{
code: 'pending_sectors',
severity: 'critical',
summary: 'Pending sectors detected (2)',
},
],
},
}),
);
expect(pending).toEqual({
label: 'Replace Now',
summary: 'Pending sectors detected (2)',
tone: 'text-red-700 dark:text-red-300',
});
expect(verdictOf(buildDisk('sdb', { temperature: 72, health: 'FAILED' })).label).toBe(
'Replace Now',
);
});
it('lets a hot disk outrank warning-level wear and SMART evidence', () => {
const wornAndHot = verdictOf(
buildDisk('sda', {
temperature: 72,
risk: {
level: 'warning',
reasons: [
{ code: 'wearout_low', severity: 'warning', summary: 'SSD life remaining is 8%' },
],
},
}),
);
expect(wornAndHot.label).toBe('Running Hot');
const wornAndWarm = verdictOf(
buildDisk('sdb', {
temperature: 52,
risk: {
level: 'warning',
reasons: [
{ code: 'wearout_low', severity: 'warning', summary: 'SSD life remaining is 8%' },
],
},
}),
);
expect(wornAndWarm).toEqual({
label: 'Needs Attention',
summary: 'SSD life remaining is 8%',
tone: 'text-amber-700 dark:text-amber-300',
});
});
it('keeps a critical level that no listed reason explains as Replace Now', () => {
const data = extractPhysicalDiskPresentationData(
buildDisk('sda', {
risk: {
level: 'critical',
reasons: [
{ code: 'crc_errors', severity: 'monitor', summary: 'UDMA CRC errors detected (2)' },
{ code: 'wearout_low', severity: 'warning', summary: 'SSD life remaining is 8%' },
],
},
}),
);
expect(getPhysicalDiskHealthStatus(data)).toEqual({
label: 'Replace Now',
summary: 'Disk health has degraded to a critical state.',
tone: 'text-red-700 dark:text-red-300',
});
});
it('decides the verdict from severities even without display text', () => {
const data = extractPhysicalDiskPresentationData(
buildDisk('sda', {
risk: {
level: 'critical',
reasons: [
{ code: 'wearout_low', severity: 'warning', summary: 'SSD life remaining is 8%' },
{ code: 'pending_sectors', severity: 'critical', summary: '' },
],
},
}),
);
expect(data.riskReasons).toEqual(['SSD life remaining is 8%']);
expect(getPhysicalDiskHealthStatus(data)).toEqual({
label: 'Replace Now',
summary: 'Disk health has degraded to a critical state.',
tone: 'text-red-700 dark:text-red-300',
});
});
it('reads severities case-insensitively and in any order within a class', () => {
const shouting = verdictOf(
buildDisk('sda', {
risk: {
level: 'CRITICAL',
reasons: [
{
code: 'media_errors',
severity: ' Critical ',
summary: 'Media errors detected (4)',
},
],
},
}),
);
expect(shouting.label).toBe('Replace Now');
expect(shouting.summary).toBe('Media errors detected (4)');
const unsorted = verdictOf(
buildDisk('sdb', {
risk: {
level: 'critical',
reasons: [
{ code: 'wearout_low', severity: 'warning', summary: 'SSD life remaining is 8%' },
{
code: 'pending_sectors',
severity: 'critical',
summary: 'Pending sectors detected (2)',
},
],
},
}),
);
expect(unsorted.summary).toBe('Pending sectors detected (2)');
});
it('sorts and filters hot disks by the action their verdict asks for', () => {
const warm = buildDisk('sdc', { temperature: 52 });
const worn = buildDisk('sdd', {
risk: {
level: 'warning',
reasons: [
{ code: 'wearout_low', severity: 'warning', summary: 'SSD life remaining is 8%' },
],
},
});
const hotDisk = buildDisk('sda', { temperature: 72 });
const failing = buildDisk('sdb', {
risk: {
level: 'critical',
reasons: [
{ code: 'media_errors', severity: 'critical', summary: 'Media errors detected (4)' },
],
},
});
const healthy = buildDisk('sde', { temperature: 38 });
const disks = [healthy, warm, worn, hotDisk, failing];
const dataMap = buildPhysicalDiskPresentationDataMap(disks);
const sorted = (healthFilter: 'all' | 'attention' | 'critical' | 'warning') =>
filterAndSortPhysicalDisks(disks, {
selectedNode: null,
healthFilter,
searchTerm: '',
getDiskData: (disk) => dataMap.get(disk.id)!,
matchesNode: () => true,
}).map((disk) => disk.id);
// Device order alone would put each hot disk first.
expect(sorted('all')).toEqual(['sdb', 'sda', 'sdd', 'sdc', 'sde']);
expect(sorted('attention')).toEqual(['sdb', 'sda', 'sdd']);
expect(sorted('critical')).toEqual(['sdb', 'sda']);
expect(sorted('warning')).toEqual(['sdd']);
});
});
it('detects SMART warnings from counters', () => {
expect(
hasPhysicalDiskSmartWarning({
@@ -25,7 +25,8 @@ export function getLinkedDiskTemperatureTextClass(
tempCelsius: number,
thresholds?: MetricDisplayThresholds | null,
): string {
if (!Number.isFinite(tempCelsius) || tempCelsius <= 0) {
// Null thresholds mean disk temperature alerting is off for this disk.
if (!Number.isFinite(tempCelsius) || tempCelsius <= 0 || thresholds === null) {
return 'text-muted';
}
const severity = getMetricSeverity(tempCelsius, 'diskTemperature', thresholds);
@@ -137,7 +138,9 @@ export function getDiskDetailAttributeCards(
cards.push({
label: 'Temperature',
value: temperature.label,
ok: getMetricSeverity(disk.temperature, 'diskTemperature', diskTempThresholds) !== 'critical',
ok:
diskTempThresholds === null ||
getMetricSeverity(disk.temperature, 'diskTemperature', diskTempThresholds) !== 'critical',
});
} else if (temperature) {
// A retained reading is neither healthy nor hot now. The collection
@@ -3,6 +3,7 @@ import type {
PhysicalDiskCollectionStatus,
PhysicalDiskFieldStatus,
Resource,
ResourceStorageRiskReason,
} from '@/types/resource';
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
import {
@@ -13,8 +14,15 @@ import {
import { getAllFilterOptionLabel } from '@/components/shared/filterOptionPresentation';
import { getPhysicalDiskNodeIdentity } from '@/components/Storage/diskResourceUtils';
import { getInfrastructureSettingsLocationLabel } from '@/utils/infrastructureSettingsPresentation';
import {
resolveDiskTemperatureDisplayThresholds,
type MetricDisplayThresholds,
} from '@/utils/metricThresholds';
import { normalizeStorageSourceKey, storageSourceMatchesFilter } from '@/utils/storageSources';
import { formatTemperature } from '@/utils/temperature';
import {
getPhysicalDiskHeatSummary,
isPhysicalDiskRunningHot,
} from './diskTemperaturePresentation';
import type { NormalizedHealth, StorageHealthFilter } from './models';
import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery';
@@ -53,6 +61,8 @@ export interface PhysicalDiskPresentationData {
health: string;
riskLevel?: string;
riskReasons: string[];
/** The same reasons with their codes and severities, as the API sent them. */
riskReasonDetails?: ResourceStorageRiskReason[];
wearout: number;
storageRole?: string;
storageGroup?: string;
@@ -65,6 +75,12 @@ export interface PhysicalDiskPresentationData {
controller?: string;
target?: string;
temperature: number;
/**
* The alert disk temperature thresholds for this disk's type. The Temp cell
* colours the reading by them and the Health verdict judges heat by them.
* Absent means the factory thresholds for the type.
*/
temperatureThresholds?: MetricDisplayThresholds | null;
rpm: number;
used: string;
collection?: PhysicalDiskCollectionStatus;
@@ -198,6 +214,7 @@ export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutM
export const getPhysicalDiskHealthCompactLabel = (label: string): string => {
if (label === 'Needs Attention') return 'Attention';
if (label === 'Replace Now') return 'Replace';
if (label === 'Running Hot') return 'Hot';
return label;
};
@@ -254,11 +271,6 @@ export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
// that would compete with it for the track.
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold';
export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium';
// A retained reading takes no threshold colour, so it cannot read as a disk
// running hot or cool right now. The dotted underline marks the value as one
// its title explains.
export const PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS =
'text-muted underline decoration-dotted underline-offset-2 cursor-help';
export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content';
const titleize = (value: string | undefined | null): string =>
@@ -397,19 +409,48 @@ export function getPhysicalDiskHostLabel(
return (disk.node || resource.parentName || '').trim();
}
/**
* Resolves the alert disk temperature thresholds for a disk type. Callers with
* the user's alert configuration pass `getDiskTemperatureThresholds` from the
* alerts activation store; the default is the factory configuration.
*/
export type PhysicalDiskTemperatureThresholdResolver = (
diskType: string,
) => MetricDisplayThresholds | null;
const resolveFactoryDiskTemperatureThresholds: PhysicalDiskTemperatureThresholdResolver = (
diskType,
) => resolveDiskTemperatureDisplayThresholds(null, diskType);
/** The thresholds the Temp cell colours this disk's reading by. */
export const getPhysicalDiskTemperatureThresholds = (
disk: Pick<PhysicalDiskPresentationData, 'temperatureThresholds' | 'type'>,
): MetricDisplayThresholds | null =>
disk.temperatureThresholds !== undefined
? disk.temperatureThresholds
: resolveFactoryDiskTemperatureThresholds(disk.type);
export function extractPhysicalDiskPresentationData(
resource: Resource,
resolveTemperatureThresholds: PhysicalDiskTemperatureThresholdResolver = resolveFactoryDiskTemperatureThresholds,
): PhysicalDiskPresentationData {
const pd = resource.physicalDisk || ((resource.platformData as any)?.physicalDisk ?? {});
const diskNode = getPhysicalDiskNodeIdentity(resource);
const riskReasons = Array.isArray(pd.risk?.reasons)
// A reason's severity decides the verdict even when it carries no display
// text, so only the summary list drops empty summaries.
const riskReasonDetails: ResourceStorageRiskReason[] = Array.isArray(pd.risk?.reasons)
? pd.risk.reasons
.map((reason: { summary?: unknown }) => reason?.summary)
.filter(
(summary: unknown): summary is string =>
typeof summary === 'string' && summary.length > 0,
)
.filter((reason: unknown) => reason !== null && typeof reason === 'object')
.map((reason: { code?: unknown; severity?: unknown; summary?: unknown }) => ({
code: typeof reason.code === 'string' ? reason.code : '',
severity: typeof reason.severity === 'string' ? reason.severity : '',
summary: typeof reason.summary === 'string' ? reason.summary : '',
}))
.filter((reason: ResourceStorageRiskReason) => reason.code || reason.summary)
: [];
const riskReasons = riskReasonDetails
.map((reason) => reason.summary)
.filter((summary) => summary.length > 0);
return {
node: diskNode.node,
@@ -426,6 +467,7 @@ export function extractPhysicalDiskPresentationData(
health: pd.health || 'UNKNOWN',
wearout: pd.wearout ?? -1,
temperature: pd.temperature ?? 0,
temperatureThresholds: resolveTemperatureThresholds(pd.diskType || ''),
rpm: pd.rpm ?? 0,
used: pd.used || '',
storageRole: pd.storageRole,
@@ -438,6 +480,7 @@ export function extractPhysicalDiskPresentationData(
collection: pd.collection,
riskLevel: pd.risk?.level,
riskReasons,
riskReasonDetails,
smartAttributes: pd.smart
? {
powerOnHours: pd.smart.powerOnHours,
@@ -473,44 +516,15 @@ export function getPhysicalDiskFieldStatusMessage(
}
}
// Normalization may keep the last known temperature when the current
// observation is not available, such as a disk in standby or a host agent that
// stopped reporting. The value is a current reading only when its collection
// state is available, or when the source predates collection state.
export function isPhysicalDiskTemperatureCurrent(
collection: PhysicalDiskCollectionStatus | null | undefined,
): boolean {
const state = collection?.temperature?.state;
return !state || state === 'available';
}
export function getPhysicalDiskLastKnownTemperatureTitle(
status: PhysicalDiskFieldStatus | null | undefined,
): string {
const reason = status?.reason?.trim();
return reason ? `Last known reading, not current: ${reason}` : 'Last known reading, not current';
}
export interface PhysicalDiskTemperaturePresentation {
label: string;
current: boolean;
/** Set only for a last-known reading: why the value is not current. */
title?: string;
}
export function getPhysicalDiskTemperaturePresentation(
disk: Pick<PhysicalDiskPresentationData, 'temperature' | 'collection'>,
): PhysicalDiskTemperaturePresentation | null {
if (!Number.isFinite(disk.temperature) || disk.temperature <= 0) return null;
const current = isPhysicalDiskTemperatureCurrent(disk.collection);
return {
label: formatTemperature(disk.temperature),
current,
title: current
? undefined
: getPhysicalDiskLastKnownTemperatureTitle(disk.collection?.temperature),
};
}
// Whether a disk temperature is a current reading lives in its own module so
// tables outside the Storage page can use it without this one.
export {
PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS,
getPhysicalDiskLastKnownTemperatureTitle,
getPhysicalDiskTemperaturePresentation,
isPhysicalDiskTemperatureCurrent,
type PhysicalDiskTemperaturePresentation,
} from './diskTemperaturePresentation';
export function getPhysicalDiskCollectionMessages(disk: PhysicalDiskPresentationData): string[] {
const collection = disk.collection;
@@ -526,17 +540,33 @@ export function getPhysicalDiskCollectionMessages(disk: PhysicalDiskPresentation
export function buildPhysicalDiskPresentationDataMap(
disks: Resource[],
resolveTemperatureThresholds?: PhysicalDiskTemperatureThresholdResolver,
): Map<string, PhysicalDiskPresentationData> {
const map = new Map<string, PhysicalDiskPresentationData>();
for (const disk of disks || []) {
map.set(disk.id, extractPhysicalDiskPresentationData(disk));
map.set(disk.id, extractPhysicalDiskPresentationData(disk, resolveTemperatureThresholds));
}
return map;
}
// Disks sort in the order their verdicts ask for action: replacement first,
// then a disk running hot, then other warnings.
const getPhysicalDiskVerdictPriority = (disk: PhysicalDiskPresentationData): number => {
const verdict = getPhysicalDiskHealthVerdict(disk);
switch (verdict.kind) {
case 'replace':
return 400;
case 'hot':
return 300;
case 'attention':
return 200;
default:
return 0;
}
};
const getPhysicalDiskPriority = (disk: PhysicalDiskPresentationData): number =>
(disk.riskLevel === 'critical' ? 300 : disk.riskLevel === 'warning' ? 200 : 0) +
(hasPhysicalDiskSmartWarning(disk) ? 50 : 0);
getPhysicalDiskVerdictPriority(disk) + (hasPhysicalDiskSmartWarning(disk) ? 50 : 0);
export function matchesPhysicalDiskSearch(
resource: Resource,
@@ -685,31 +715,102 @@ export function hasPhysicalDiskSmartWarning(disk: PhysicalDiskPresentationData):
);
}
const PHYSICAL_DISK_RISK_SEVERITY_RANK: Record<string, number> = {
monitor: 1,
warning: 2,
critical: 3,
};
const getPhysicalDiskRiskSeverityRank = (severity: string | undefined): number =>
PHYSICAL_DISK_RISK_SEVERITY_RANK[(severity || '').trim().toLowerCase()] ?? 0;
interface PhysicalDiskRiskEvidence {
rank: number;
summary?: string;
}
// Keeps the disk's most severe risk reason. A risk level that none of its
// reasons explains, or one sent without reasons, still sets the rank.
function getPhysicalDiskRiskEvidence(disk: PhysicalDiskPresentationData): PhysicalDiskRiskEvidence {
const evidence: PhysicalDiskRiskEvidence = { rank: 0 };
const details =
disk.riskReasonDetails ??
disk.riskReasons.map((summary) => ({ code: '', severity: disk.riskLevel || '', summary }));
for (const reason of details) {
const rank = getPhysicalDiskRiskSeverityRank(reason.severity);
if (rank > evidence.rank) {
evidence.rank = rank;
evidence.summary = reason.summary || undefined;
} else if (rank === evidence.rank && !evidence.summary) {
evidence.summary = reason.summary || undefined;
}
}
const levelRank = getPhysicalDiskRiskSeverityRank(disk.riskLevel);
if (levelRank > evidence.rank) {
// A reason with a known, lower severity does not explain this level, so
// its text must not stand in as the verdict's reason.
if (evidence.rank > 0) evidence.summary = undefined;
evidence.rank = levelRank;
}
return evidence;
}
type PhysicalDiskHealthVerdict =
| { kind: 'replace'; summary: string }
| { kind: 'hot'; summary: string }
| { kind: 'attention'; summary: string }
| { kind: 'clear' };
function getPhysicalDiskHealthVerdict(
disk: PhysicalDiskPresentationData,
): PhysicalDiskHealthVerdict {
const health = getPhysicalDiskRiskEvidence(disk);
const critical = PHYSICAL_DISK_RISK_SEVERITY_RANK.critical;
const warning = PHYSICAL_DISK_RISK_SEVERITY_RANK.warning;
const lowLife = isPhysicalDiskWearoutReported(disk) && disk.wearout < 10;
if (normalizePhysicalDiskHealth(disk.health) === 'FAILED' || health.rank >= critical) {
return {
kind: 'replace',
summary: health.summary || 'Disk health has degraded to a critical state.',
};
}
const temperatureThresholds = getPhysicalDiskTemperatureThresholds(disk);
if (isPhysicalDiskRunningHot(disk, temperatureThresholds)) {
return {
kind: 'hot',
summary: getPhysicalDiskHeatSummary(disk.temperature, temperatureThresholds),
};
}
if (health.rank >= warning || hasPhysicalDiskSmartWarning(disk) || lowLife) {
return {
kind: 'attention',
summary:
health.summary ||
(lowLife ? 'SSD life is running low.' : 'SMART counters indicate elevated risk.'),
};
}
return { kind: 'clear' };
}
const PHYSICAL_DISK_CRITICAL_TONE = 'text-red-700 dark:text-red-300';
const PHYSICAL_DISK_WARNING_TONE = 'text-amber-700 dark:text-amber-300';
export function getPhysicalDiskHealthStatus(
disk: PhysicalDiskPresentationData,
): DiskHealthStatusPresentation {
const normalizedHealth = normalizePhysicalDiskHealth(disk.health);
const criticalRisk = (disk.riskLevel || '').trim().toLowerCase() === 'critical';
const warningRisk = (disk.riskLevel || '').trim().toLowerCase() === 'warning';
const smartWarning = hasPhysicalDiskSmartWarning(disk);
const lowLife = isPhysicalDiskWearoutReported(disk) && disk.wearout < 10;
const verdict = getPhysicalDiskHealthVerdict(disk);
if (normalizedHealth === 'FAILED' || criticalRisk) {
return {
label: 'Replace Now',
summary: disk.riskReasons[0] || 'Disk health has degraded to a critical state.',
tone: 'text-red-700 dark:text-red-300',
};
if (verdict.kind === 'replace') {
return { label: 'Replace Now', summary: verdict.summary, tone: PHYSICAL_DISK_CRITICAL_TONE };
}
if (warningRisk || smartWarning || lowLife) {
return {
label: 'Needs Attention',
summary:
disk.riskReasons[0] ||
(lowLife ? 'SSD life is running low.' : 'SMART counters indicate elevated risk.'),
tone: 'text-amber-700 dark:text-amber-300',
};
if (verdict.kind === 'hot') {
return { label: 'Running Hot', summary: verdict.summary, tone: PHYSICAL_DISK_CRITICAL_TONE };
}
if (verdict.kind === 'attention') {
return { label: 'Needs Attention', summary: verdict.summary, tone: PHYSICAL_DISK_WARNING_TONE };
}
if (isUnraidPhysicalDisk(disk) && !hasUnraidPhysicalDiskFaultSignal(disk)) {
@@ -720,7 +821,7 @@ export function getPhysicalDiskHealthStatus(
};
}
const isHealthy = PHYSICAL_DISK_HEALTHY_STATES.has(normalizedHealth);
const isHealthy = PHYSICAL_DISK_HEALTHY_STATES.has(normalizePhysicalDiskHealth(disk.health));
return {
label: isHealthy ? 'Healthy' : 'Unknown',
summary: isHealthy ? 'No active disk-health issues.' : 'Health state is not reported.',
@@ -732,13 +833,16 @@ export function getPhysicalDiskNormalizedHealth(
resource: Resource,
disk: PhysicalDiskPresentationData,
): NormalizedHealth {
const status = getPhysicalDiskHealthStatus(disk).label;
if (status === 'Online') return 'healthy';
const verdict = getPhysicalDiskHealthVerdict(disk);
const clearLabel = verdict.kind === 'clear' ? getPhysicalDiskHealthStatus(disk).label : null;
if (clearLabel === 'Online') return 'healthy';
if (resource.status === 'offline') return 'offline';
if (status === 'Replace Now') return 'critical';
if (status === 'Needs Attention') return 'warning';
if (status === 'Healthy') return 'healthy';
return 'unknown';
if (verdict.kind === 'replace') return 'critical';
// A disk running hot sits in the health filter with the critical disks, as
// its Temp cell reads red.
if (verdict.kind === 'hot') return 'critical';
if (verdict.kind === 'attention') return 'warning';
return clearLabel === 'Healthy' ? 'healthy' : 'unknown';
}
export function matchesPhysicalDiskHealthFilter(
@@ -0,0 +1,80 @@
import type { PhysicalDiskCollectionStatus, PhysicalDiskFieldStatus } from '@/types/resource';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { formatTemperature } from '@/utils/temperature';
// The canonical decision on whether a disk temperature is a current reading.
// It lives apart from diskPresentation so the Machines table, the machine
// drawer and the guest drawer can use it without pulling the Storage page
// presentation module into their chunks.
// A retained reading takes no threshold colour, so it cannot read as a disk
// running hot or cool right now. The dotted underline marks the value as one
// its title explains.
export const PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS =
'text-muted underline decoration-dotted underline-offset-2 cursor-help';
// Normalization may keep the last known temperature when the current
// observation is not available, such as a disk in standby or a host agent that
// stopped reporting. The value is a current reading only when its collection
// state is available, or when the source predates collection state.
export function isPhysicalDiskTemperatureCurrent(
collection: PhysicalDiskCollectionStatus | null | undefined,
): boolean {
const state = collection?.temperature?.state;
return !state || state === 'available';
}
export function getPhysicalDiskLastKnownTemperatureTitle(
status: PhysicalDiskFieldStatus | null | undefined,
): string {
const reason = status?.reason?.trim();
return reason ? `Last known reading, not current: ${reason}` : 'Last known reading, not current';
}
export interface PhysicalDiskTemperaturePresentation {
label: string;
current: boolean;
/** Set only for a last-known reading: why the value is not current. */
title?: string;
}
export function getPhysicalDiskTemperaturePresentation(disk: {
temperature: number;
collection?: PhysicalDiskCollectionStatus | null;
}): PhysicalDiskTemperaturePresentation | null {
if (!Number.isFinite(disk.temperature) || disk.temperature <= 0) return null;
const current = isPhysicalDiskTemperatureCurrent(disk.collection);
return {
label: formatTemperature(disk.temperature),
current,
title: current
? undefined
: getPhysicalDiskLastKnownTemperatureTitle(disk.collection?.temperature),
};
}
// Disk heat is judged by the alert disk temperature policy, through the
// thresholds the Temp column colours a reading by: a disk runs hot from its
// type's alert trigger (`critical`), the reading its disk temperature alert
// fires at. The band just below the trigger is a colour, not heat. A retained
// reading is not current, and null thresholds mean disk temperature alerting
// is off for the disk, so neither is ever hot. Disk risk never carries heat.
export function isPhysicalDiskRunningHot(
disk: { temperature: number; collection?: PhysicalDiskCollectionStatus | null },
thresholds: MetricDisplayThresholds | null,
): boolean {
if (!thresholds || !(thresholds.critical > 0)) return false;
if (!Number.isFinite(disk.temperature) || disk.temperature <= 0) return false;
if (!isPhysicalDiskTemperatureCurrent(disk.collection)) return false;
return disk.temperature >= thresholds.critical;
}
export function getPhysicalDiskHeatSummary(
temperature: number,
thresholds: MetricDisplayThresholds | null,
): string {
const reading = formatTemperature(temperature);
return thresholds && thresholds.critical > 0
? `Disk temperature is ${reading}, at or above its ${formatTemperature(thresholds.critical)} alert threshold.`
: `Disk temperature is ${reading}.`;
}
@@ -37,13 +37,15 @@ import {
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import { useAlertsActivation } from '@/stores/alertsActivation';
import type { Resource } from '@/types/resource';
import {
buildTrueNASStorageTopologyRows,
filterTrueNASStorageTopologyRows,
filterTrueNASStorageTopologyRowsByKind,
getTrueNASResourceDisplayStatus,
getTrueNASStorageDotStatus,
getTrueNASStorageIssue,
type TrueNASDiskTemperatureThresholdResolver,
type TrueNASStorageIssue,
type TrueNASStorageStatusFilter,
type TrueNASStorageKindFilter,
@@ -232,6 +234,7 @@ type TrueNASStorageSortKey = (typeof TRUENAS_STORAGE_SORT_KEYS)[number];
const getTrueNASStorageSortValue = (
row: TrueNASStorageTopologyRow,
key: TrueNASStorageSortKey,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): PlatformTableSortValue => {
switch (key) {
case 'resource':
@@ -255,7 +258,9 @@ const getTrueNASStorageSortValue = (
return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null;
}
case 'health':
return HEALTH_SORT_RANK[getTrueNASStorageIssue(row.resource)?.status ?? 'healthy'];
return HEALTH_SORT_RANK[
getTrueNASStorageIssue(row.resource, resolveDiskTemperatureThresholds)?.status ?? 'healthy'
];
default:
key satisfies never;
return null;
@@ -271,6 +276,7 @@ const getTrueNASStorageSortValue = (
const sortTrueNASStorageTopologyRows = (
rows: readonly TrueNASStorageTopologyRow[],
sort: PlatformTableSortState<TrueNASStorageSortKey>,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): readonly TrueNASStorageTopologyRow[] => {
if (!sort.sortKey()) return rows;
const present = new Set(rows.map((row) => row.id));
@@ -288,7 +294,9 @@ const sortTrueNASStorageTopologyRows = (
}
const ordered: TrueNASStorageTopologyRow[] = [];
const emit = (siblings: TrueNASStorageTopologyRow[]) => {
for (const row of sort.sortRows(siblings, getTrueNASStorageSortValue)) {
for (const row of sort.sortRows(siblings, (sibling, key) =>
getTrueNASStorageSortValue(sibling, key, resolveDiskTemperatureThresholds),
)) {
ordered.push(row);
const children = childrenByParent.get(row.id);
if (children) emit(children);
@@ -308,7 +316,9 @@ export const getTrueNASStorageTopologyIndentClass = (depth: number): string => {
const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: JSX.Element }> = (
props,
) => {
const displayStatus = () => getTrueNASResourceDisplayStatus(props.row.resource);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const displayStatus = () =>
getTrueNASStorageDotStatus(props.row.resource, getDiskTemperatureThresholds);
const indicator = () => getSimpleStatusIndicator(displayStatus());
const name = () => resourceName(props.row.resource);
return (
@@ -342,7 +352,11 @@ export const TrueNASStorageTopologyTable: Component<{
kindFilter?: TrueNASStorageKindFilter;
onKindFilterChange?: (value: TrueNASStorageKindFilter) => void;
}> = (props) => {
const rows = createMemo(() => buildTrueNASStorageTopologyRows(props.resources));
// Disk heat follows the user's alert disk temperature thresholds.
const { getDiskTemperatureThresholds } = useAlertsActivation();
const rows = createMemo(() =>
buildTrueNASStorageTopologyRows(props.resources, getDiskTemperatureThresholds),
);
const [internalKindFilter, setInternalKindFilter] = createSignal<TrueNASStorageKindFilter>('all');
const kindFilter = () => props.kindFilter ?? internalKindFilter();
const setKindFilter = (value: TrueNASStorageKindFilter) => {
@@ -356,7 +370,8 @@ export const TrueNASStorageTopologyTable: Component<{
const tableState = createPlatformTableFilterState({
resources: scopedRows,
initialStatus: 'all' as TrueNASStorageStatusFilter,
filter: filterTrueNASStorageTopologyRows,
filter: (rows, search, status) =>
filterTrueNASStorageTopologyRows(rows, search, status, getDiskTemperatureThresholds),
});
const kindFilters = createMemo<FilterDef[]>(() => [
{
@@ -373,6 +388,7 @@ export const TrueNASStorageTopologyTable: Component<{
filterTrueNASStorageTopologyRowsByKind(rows(), option.value),
tableState.search(),
tableState.status(),
getDiskTemperatureThresholds,
).length,
})),
value: kindFilter,
@@ -392,7 +408,9 @@ export const TrueNASStorageTopologyTable: Component<{
sortKeys: TRUENAS_STORAGE_SORT_KEYS,
descendingFirst: ['usage', 'disks', 'temp'],
});
const sortedRows = createMemo(() => sortTrueNASStorageTopologyRows(tableState.filtered(), sort));
const sortedRows = createMemo(() =>
sortTrueNASStorageTopologyRows(tableState.filtered(), sort, getDiskTemperatureThresholds),
);
return (
<Show
@@ -499,7 +517,9 @@ export const TrueNASStorageTopologyTable: Component<{
<PlatformWindowedRows items={sortedRows} estimatedRowHeight={32}>
{(row) => {
const resource = () => row.resource;
const issue = createMemo(() => getTrueNASStorageIssue(resource()));
const issue = createMemo(() =>
getTrueNASStorageIssue(resource(), getDiskTemperatureThresholds),
);
const detailRowId = () => drawer.detailRowId(resource());
const isExpanded = () => drawer.isExpanded(resource());
return (
@@ -1,5 +1,10 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertsAPI } from '@/api/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { eventBus } from '@/stores/events';
import type { AlertConfig } from '@/types/alerts';
import {
TrueNASStorageTopologyTable,
@@ -215,4 +220,102 @@ describe('TrueNASStorageTopologyTable', () => {
expect(healthyRow?.querySelector('[data-truenas-storage-health]')).toBeNull();
expect(healthyRow).not.toHaveTextContent('Healthy');
});
it('flags a disk running hot by its type alert trigger', () => {
// Disk risk carries no heat, so the table judges it with the alerts
// store's per-type thresholds (factory here: SATA 55C, NVMe 70C).
const disk = (id: string, diskType: string, temperature: number) =>
makeStorageResource({
id,
type: 'physical_disk',
name: id,
storage: undefined,
physicalDisk: { devPath: `/dev/${id}`, serial: `serial-${id}`, diskType, temperature },
});
const resources = [disk('sda', 'sata', 56), disk('nvme0n1', 'nvme', 63)];
const { container } = render(() => (
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
showToolbar={false}
/>
));
const hotRow = container.querySelector('[data-truenas-storage-resource="sda"]');
expect(hotRow?.querySelector('[data-truenas-storage-health="attention"]')).toHaveAttribute(
'title',
'Disk temperature is 56°C, at or above its 55°C alert threshold.',
);
const warmRow = container.querySelector('[data-truenas-storage-resource="nvme0n1"]');
expect(warmRow?.querySelector('[data-truenas-storage-health]')).toBeNull();
// Phones hide the Health cell, so the status dot carries the heat too.
expect(hotRow?.querySelector('[title="Warning"]')).not.toBeNull();
expect(warmRow?.querySelector('[title="Online"]')).not.toBeNull();
// The hot disk sorts ahead of its cooler sibling.
expect(
[...container.querySelectorAll('[data-truenas-storage-resource]')].map((row) =>
row.getAttribute('data-truenas-storage-resource'),
),
).toEqual(['sda', 'nvme0n1']);
});
it('follows a disk temperature trigger the user raised in Alerts', async () => {
const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({
enabled: true,
activationState: 'active',
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: {
nvme: { trigger: 70, clear: 65 },
sas: { trigger: 65, clear: 60 },
sata: { trigger: 60, clear: 55 },
},
} as unknown as AlertConfig);
try {
const resources = [
makeStorageResource({
id: 'sda',
type: 'physical_disk',
name: 'sda',
storage: undefined,
physicalDisk: {
devPath: '/dev/sda',
serial: 'serial-sda',
diskType: 'sata',
temperature: 56,
},
}),
];
const { container } = render(() => (
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
/>
));
const health = () =>
container.querySelector(
'[data-truenas-storage-resource="sda"] [data-truenas-storage-health]',
);
expect(health()).toHaveAttribute('data-truenas-storage-health', 'attention');
expect(screen.getByRole('button', { name: /Attention/ })).toHaveTextContent('1');
// With the Attention filter on, the storage-type counts judge heat too.
await fireEvent.click(screen.getByRole('button', { name: /Attention/ }));
expect(screen.getByRole('button', { name: 'Physical disks, 1' })).toBeInTheDocument();
await useAlertsActivation().refreshConfig();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Physical disks, 0' })).toBeInTheDocument(),
);
// The status filter counts follow the same thresholds.
expect(screen.getByRole('button', { name: /Attention/ })).toHaveTextContent('0');
} finally {
getConfig.mockRestore();
eventBus.emit('org_switched', 'default');
}
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { RecoveryPoint } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import { resolveDiskTemperatureDisplayThresholds } from '@/utils/metricThresholds';
import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
@@ -630,6 +631,82 @@ describe('truenasPageModel', () => {
expect(mapTrueNASStorageStatus(healthyByState)).toBe('healthy');
});
it('flags a disk whose current reading reaches its alert trigger', () => {
// Disk risk carries no heat, so a hot disk stays `online`; the page judges
// it by the alert disk temperature thresholds, as Physical Disks does.
const resolveFactory = (diskType: string) =>
resolveDiskTemperatureDisplayThresholds(null, diskType);
const hotDisk = makeResource({
id: 'disk-hot',
type: 'physical_disk',
name: 'sda',
status: 'online',
physicalDisk: {
devPath: '/dev/sda',
serial: 'serial-hot',
diskType: 'sata',
health: 'UNKNOWN',
temperature: 56,
},
});
expect(mapTrueNASStorageStatus(hotDisk, resolveFactory)).toBe('attention');
expect(getTrueNASStorageIssue(hotDisk, resolveFactory)).toEqual({
status: 'attention',
label: 'Attention',
reasons: ['Disk temperature is 56°C, at or above its 55°C alert threshold.'],
});
// A cool sibling that sorts first by name: heat moves the hot disk ahead
// of it, and the status filter splits them.
const coolDisk = makeResource({
...hotDisk,
id: 'disk-cool',
name: 'ada0',
physicalDisk: { ...hotDisk.physicalDisk!, serial: 'serial-cool', temperature: 40 },
});
const rows = buildTrueNASStorageTopologyRows([coolDisk, hotDisk], resolveFactory);
expect(rows.map((row) => row.id)).toEqual(['disk:disk-hot', 'disk:disk-cool']);
expect(buildTrueNASStorageTopologyRows([coolDisk, hotDisk]).map((row) => row.id)).toEqual([
'disk:disk-cool',
'disk:disk-hot',
]);
expect(
filterTrueNASStorageTopologyRows(rows, '', 'attention', resolveFactory).map((row) => row.id),
).toEqual(['disk:disk-hot']);
expect(
filterTrueNASStorageTopologyRows(rows, '', 'healthy', resolveFactory).map((row) => row.id),
).toEqual(['disk:disk-cool']);
// Native TrueNAS alert text leads, and the heat reason stays listed.
const hotWithIncident = makeResource({
...hotDisk,
incidents: [{ code: 'truenas_smart', severity: 'info', summary: 'SMART test scheduled' }],
});
expect(getTrueNASStorageIssue(hotWithIncident, resolveFactory)?.reasons).toEqual([
'SMART test scheduled',
'Disk temperature is 56°C, at or above its 55°C alert threshold.',
]);
// An NVMe at 63C is under its 70C trigger.
const warmNVMe = makeResource({
...hotDisk,
id: 'disk-nvme',
physicalDisk: { ...hotDisk.physicalDisk!, diskType: 'nvme', temperature: 63 },
});
expect(mapTrueNASStorageStatus(warmNVMe, resolveFactory)).toBe('healthy');
// A trigger the user raised, alerting switched off, and a retained reading
// are never heat.
expect(mapTrueNASStorageStatus(hotDisk, () => ({ warning: 55, critical: 60 }))).toBe('healthy');
expect(mapTrueNASStorageStatus(hotDisk, () => null)).toBe('healthy');
const retained = makeResource({
...hotDisk,
physicalDisk: {
...hotDisk.physicalDisk!,
collection: { temperature: { state: 'unavailable', source: 'truenas', reason: 'stale' } },
},
});
expect(mapTrueNASStorageStatus(retained, resolveFactory)).toBe('healthy');
});
it('renders expected receive-side replication readonly posture as healthy', () => {
const replicatedDataset = makeResource({
id: 'dataset-replica',
@@ -2,6 +2,11 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import { asTrimmedString } from '@/utils/stringUtils';
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
import { getTrueNASDatasetStateSummary } from '@/utils/truenasDatasetState';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import {
getPhysicalDiskHeatSummary,
isPhysicalDiskRunningHot,
} from '@/features/storageBackups/diskTemperaturePresentation';
import type {
Resource,
ResourceIncident,
@@ -402,7 +407,10 @@ export function buildTrueNASStorageChildCounts(
export function buildTrueNASStorageTopologyRows(
resources: Resource[],
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): TrueNASStorageTopologyRow[] {
const compareStorageResources = (left: Resource, right: Resource): number =>
compareStorageResourcesByStatus(left, right, resolveDiskTemperatureThresholds);
const storageResources = resources.filter(
(resource) =>
isTrueNASPlatform(resource) &&
@@ -556,8 +564,14 @@ function filterTrueNASStorageTopologyRow(
row: TrueNASStorageTopologyRow,
search: string,
status: TrueNASStorageStatusFilter,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): boolean {
if (status !== 'all' && mapTrueNASStorageStatus(row.resource) !== status) return false;
if (
status !== 'all' &&
mapTrueNASStorageStatus(row.resource, resolveDiskTemperatureThresholds) !== status
) {
return false;
}
if (!search.trim()) return true;
if (row.kind.includes(search.trim().toLowerCase())) return true;
return matchesTrueNASStorageSearch(row.resource, search);
@@ -567,9 +581,14 @@ export function filterTrueNASStorageTopologyRows(
rows: TrueNASStorageTopologyRow[],
search: string,
status: TrueNASStorageStatusFilter,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): TrueNASStorageTopologyRow[] {
const directMatches = new Set(
rows.filter((row) => filterTrueNASStorageTopologyRow(row, search, status)).map((row) => row.id),
rows
.filter((row) =>
filterTrueNASStorageTopologyRow(row, search, status, resolveDiskTemperatureThresholds),
)
.map((row) => row.id),
);
if (directMatches.size === rows.length) return rows;
@@ -628,8 +647,11 @@ const titleize = (value: string): string =>
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(' ');
const storageStatusRank = (resource: Resource): number => {
switch (mapTrueNASStorageStatus(resource)) {
const storageStatusRank = (
resource: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): number => {
switch (mapTrueNASStorageStatus(resource, resolveDiskTemperatureThresholds)) {
case 'attention':
return 0;
case 'offline':
@@ -641,8 +663,14 @@ const storageStatusRank = (resource: Resource): number => {
}
};
const compareStorageResources = (left: Resource, right: Resource): number => {
const rankDelta = storageStatusRank(left) - storageStatusRank(right);
const compareStorageResourcesByStatus = (
left: Resource,
right: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): number => {
const rankDelta =
storageStatusRank(left, resolveDiskTemperatureThresholds) -
storageStatusRank(right, resolveDiskTemperatureThresholds);
if (rankDelta !== 0) return rankDelta;
const nameDelta = resourceDisplayName(left).localeCompare(resourceDisplayName(right));
if (nameDelta !== 0) return nameDelta;
@@ -699,8 +727,45 @@ export function mapTrueNASIncidentSeverity(
return 'info';
}
/**
* Resolves a disk type's alert disk temperature thresholds: the alerts store's
* `getDiskTemperatureThresholds`. Without one, disk heat is not judged.
*/
export type TrueNASDiskTemperatureThresholdResolver = (
diskType: string,
) => MetricDisplayThresholds | null;
// Disk risk carries no heat, so a disk whose current reading has reached its
// type's alert trigger is judged here, by the same policy as the Physical
// Disks Running Hot verdict. Returns the heat reason, or null.
function getTrueNASDiskHeatSummary(
resource: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): string | null {
const disk = resource.physicalDisk;
if (!resolveDiskTemperatureThresholds || resource.type !== 'physical_disk' || !disk) return null;
const temperature = disk.temperature ?? 0;
const thresholds = resolveDiskTemperatureThresholds(disk.diskType || '');
return isPhysicalDiskRunningHot({ temperature, collection: disk.collection }, thresholds)
? getPhysicalDiskHeatSummary(temperature, thresholds)
: null;
}
// A hot disk reports `online` (disk risk carries no heat), so its status dot
// takes the warning tone its Health cell gives it. On phones the dot is the
// row's only health signal.
export function getTrueNASStorageDotStatus(
resource: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): string {
const status = getTrueNASResourceDisplayStatus(resource);
if (normalize(status) !== 'online') return status;
return getTrueNASDiskHeatSummary(resource, resolveDiskTemperatureThresholds) ? 'warning' : status;
}
export function mapTrueNASStorageStatus(
resource: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): Exclude<TrueNASStorageStatusFilter, 'all'> | 'unknown' {
const status = normalize(getTrueNASResourceDisplayStatus(resource));
const zfsState = normalize(resource.storage?.zfsPoolState);
@@ -719,6 +784,7 @@ export function mapTrueNASStorageStatus(
if (diskHealth && !['passed', 'healthy', 'ok', 'unknown', 'unavailable'].includes(diskHealth)) {
return 'attention';
}
if (getTrueNASDiskHeatSummary(resource, resolveDiskTemperatureThresholds)) return 'attention';
if (['online', 'running', 'healthy'].includes(status)) return 'healthy';
return 'unknown';
}
@@ -753,8 +819,11 @@ const reasonKey = (text: string): string =>
// the failing member or test, so it leads. Pulse's derived risk summaries
// restate the same pool and disk state, so they only stand in when TrueNAS
// raised no alert; the row drawer still lists every one of them.
export function getTrueNASStorageIssue(resource: Resource): TrueNASStorageIssue | null {
const status = mapTrueNASStorageStatus(resource);
export function getTrueNASStorageIssue(
resource: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): TrueNASStorageIssue | null {
const status = mapTrueNASStorageStatus(resource, resolveDiskTemperatureThresholds);
if (status === 'healthy') return null;
const reasons: string[] = [];
@@ -776,6 +845,9 @@ export function getTrueNASStorageIssue(resource: Resource): TrueNASStorageIssue
for (const reason of resource.storage?.risk?.reasons ?? []) push(reason.summary);
for (const reason of resource.physicalDisk?.risk?.reasons ?? []) push(reason.summary);
}
// Heat is Pulse's own judgement, not a restatement of TrueNAS state, so it
// stays listed beside any native alert text.
push(getTrueNASDiskHeatSummary(resource, resolveDiskTemperatureThresholds));
// A read-only dataset raises no incident, so its state tag is the only
// reason the provider gives. The drawer reads the same shared mapping.
if (reasons.length === leading && resource.storage?.topology === 'dataset') {
@@ -474,7 +474,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
statusModeStorageScope: VMWARE_WORKLOAD_STATUS_STORAGE_SCOPE,
columnVisibilityStorageScope: VMWARE_WORKLOAD_COLUMN_VISIBILITY_SCOPE,
additionalDefaultHiddenColumnIds: [...VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS],
compactGroupHeaders: true,
inventorySourcesQuery: props.inventorySourcesQuery,
groupNodeDrawerMode: 'disabled',
metricDisplayMode: props.metricDisplayMode,
@@ -545,7 +544,6 @@ function VmwareOverview(props: VmwareOverviewProps) {
useWorkloads
forcedPlatform={VMWARE_PLATFORM_FILTER}
forcedViewMode="vm"
compactGroupHeaders
groupNodeDrawerMode="disabled"
suppressFilterToolbar
emptyStateTitle="No vSphere VMs"
+3
View File
@@ -626,6 +626,9 @@ export interface HostDiskSMART {
temperature: number; // Temperature in Celsius
health?: string; // PASSED, FAILED, UNKNOWN
standby?: boolean; // True if disk was in standby
// A non-available temperature state marks the value as last known, such as
// a host agent that stopped reporting.
collection?: import('./resource').PhysicalDiskCollectionStatus;
}
export interface HostRAIDArray {
@@ -374,6 +374,29 @@ describe('metricThresholds', () => {
});
});
it('switches every disk type off with the agent disk temperature default', () => {
const config = {
enabled: true,
guestDefaults: {},
nodeDefaults: {},
agentDefaults: { diskTemperature: { trigger: 0, clear: 0 } },
diskTempByType: {
nvme: { trigger: 70, clear: 65 },
sata: { trigger: 55, clear: 50 },
},
storageDefault: { trigger: 85, clear: 80 },
overrides: { 'host-1': { diskTemperature: { trigger: 80, clear: 75 } } },
} as AlertConfig;
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme')).toBeNull();
expect(resolveDiskTemperatureDisplayThresholds(config, 'sata')).toBeNull();
expect(resolveDiskTemperatureDisplayThresholds(config, '')).toBeNull();
// An explicit host override still applies, as CheckHost applies it.
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme', 'host-1')).toEqual({
warning: 75,
critical: 80,
});
});
it('falls back to seeded per-type disk temperature defaults without config', () => {
expect(resolveDiskTemperatureDisplayThresholds(null, 'nvme')).toEqual({
warning: 65,
@@ -130,6 +130,12 @@ describe('temperature', () => {
expect(getTemperatureTextClass(80)).toContain('text-red-600');
});
it('colours no disk reading whose disk temperature alerting is off', () => {
expect(getTemperatureTextClass(80, null, 'diskTemperature')).toBe('text-base-content');
// Node temperatures keep the factory colours without configured thresholds.
expect(getTemperatureTextClass(80, null, 'temperature')).toContain('text-red-600');
});
it('honors configured warning and critical thresholds', () => {
const thresholds = { warning: 80, critical: 85 };
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest';
import nodeDrawerOverviewSource from '@/components/Workloads/NodeDrawerOverview.tsx?raw';
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import nodeGroupHeaderSource from '@/components/shared/NodeGroupHeader.tsx?raw';
import temperatureGaugeSource from '@/components/shared/TemperatureGauge.tsx?raw';
import dockerHostDrawerOverviewSource from '@/features/docker/DockerHostDrawerOverview.tsx?raw';
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
describe('temperature display guardrails', () => {
it('keeps colored temperature displays threshold-aware', () => {
expect(nodeGroupHeaderSource).toContain('temperatureThresholds');
expect(workloadPanelSource).toContain('getNodeTemperatureThresholds(node)');
expect(workloadPanelSource).toContain(
'temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}',
);
expect(nodeDrawerOverviewSource).toContain('getTemperatureTextClass(value, thresholds)');
expect(dockerHostDrawerOverviewSource).toContain('temperatureThresholds()');
expect(agentsMachinesTableSource).toContain('getAgentMachineTemperatureMetric');
@@ -19,7 +19,6 @@ describe('temperature display guardrails', () => {
});
it('does not reintroduce the previous local temperature severity calls', () => {
expect(nodeGroupHeaderSource).not.toContain('getTemperatureTextClass(cpuTemperature())');
expect(workloadPanelSource).not.toContain('getTemperatureTextClass(temperature)}');
expect(nodeDrawerOverviewSource).not.toContain('getTemperatureTextClass(value)');
expect(dockerHostDrawerOverviewSource).not.toContain('getTemperatureTextClass(temperature)');
@@ -269,7 +269,9 @@ export const resolveMetricDisplayThresholds = (
* Resolve display thresholds for a physical disk's SMART temperature.
* Mirrors the backend precedence: an explicit diskTemperature override on the
* host (or inherited linked resource) wins, then the per-type map
* (diskTempByType: nvme/sas/sata), then the global agent default.
* (diskTempByType: nvme/sas/sata), then the global agent default. A
* switched-off agent default switches every type off, as disk temperature
* alerts do. Null means disk temperature alerting is off for this disk.
*/
export const resolveDiskTemperatureDisplayThresholds = (
config: AlertConfig | null,
@@ -285,6 +287,11 @@ export const resolveDiskTemperatureDisplayThresholds = (
return resolveThreshold(overrideValue, FACTORY_AGENT_DEFAULTS.diskTemperature, margin);
}
const baseValue = getBaseThresholdValue(config?.agentDefaults, 'diskTemperature');
if (baseValue !== undefined && resolveThreshold(baseValue, undefined, margin) === null) {
return null;
}
const byTypeFallback = normalizedType ? FACTORY_DISK_TEMP_BY_TYPE[normalizedType] : undefined;
if (normalizedType) {
const byType = config?.diskTempByType?.[normalizedType];
@@ -293,7 +300,6 @@ export const resolveDiskTemperatureDisplayThresholds = (
}
}
const baseValue = getBaseThresholdValue(config?.agentDefaults, 'diskTemperature');
return resolveThreshold(
baseValue,
byTypeFallback ?? FACTORY_AGENT_DEFAULTS.diskTemperature,
+5
View File
@@ -123,6 +123,11 @@ export const getTemperatureTextClass = (
if (celsius === null || celsius === undefined || !Number.isFinite(celsius)) {
return 'text-muted';
}
// Null disk thresholds mean disk temperature alerting is off for that disk,
// so no reading of it is warm or hot.
if (metric === 'diskTemperature' && thresholds === null) {
return 'text-base-content';
}
const readingSeverity = getMetricSeverity(
celsius,
metric,
+16
View File
@@ -68,6 +68,22 @@ func (a *AlertThresholdAdapter) GetGuestDiskThreshold() float64 {
}, 90)
}
// GetDiskTemperatureThreshold returns the disk temperature alert trigger and
// clear values for a disk type, resolved exactly as disk temperature alerts
// resolve them (alerts.Manager.DiskTemperatureThreshold).
func (a *AlertThresholdAdapter) GetDiskTemperatureThreshold(diskType string) (float64, float64) {
var threshold *alerts.HysteresisThreshold
if a.manager != nil {
threshold = a.manager.DiskTemperatureThreshold(diskType)
} else {
threshold = alerts.DefaultDiskTemperatureThreshold(diskType)
}
if threshold == nil || threshold.Trigger <= 0 {
return 0, 0
}
return threshold.Trigger, threshold.Clear
}
// GetStorageThreshold returns the usage alert trigger threshold for storage (0-100%)
func (a *AlertThresholdAdapter) GetStorageThreshold() float64 {
return a.getThreshold(func(cfg alerts.AlertConfig) (float64, bool) {
@@ -82,3 +82,28 @@ func TestAlertThresholdAdapter_Fallbacks(t *testing.T) {
t.Fatalf("GetStorageThreshold default = %v", a.GetStorageThreshold())
}
}
func TestAlertThresholdAdapter_DiskTemperatureFollowsAlertPolicy(t *testing.T) {
if trigger, clear := NewAlertThresholdAdapter(nil).GetDiskTemperatureThreshold("nvme"); trigger != 70 || clear != 65 {
t.Fatalf("no manager: nvme = %v/%v, want the factory 70/65", trigger, clear)
}
mgr := alerts.NewManager()
cfg := mgr.GetConfig()
cfg.DiskTempByType["nvme"] = alerts.HysteresisThreshold{Trigger: 75, Clear: 70}
mgr.UpdateConfig(cfg)
a := NewAlertThresholdAdapter(mgr)
if trigger, clear := a.GetDiskTemperatureThreshold("nvme"); trigger != 75 || clear != 70 {
t.Fatalf("raised nvme = %v/%v, want 75/70", trigger, clear)
}
if trigger, clear := a.GetDiskTemperatureThreshold("sata"); trigger != 55 || clear != 50 {
t.Fatalf("sata = %v/%v, want 55/50", trigger, clear)
}
cfg = mgr.GetConfig()
cfg.AgentDefaults.DiskTemperature = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
mgr.UpdateConfig(cfg)
if trigger, clear := a.GetDiskTemperatureThreshold("nvme"); trigger != 0 || clear != 0 {
t.Fatalf("disk temperature alerting off: nvme = %v/%v, want 0/0", trigger, clear)
}
}
+13
View File
@@ -5,6 +5,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -51,6 +52,9 @@ type mockThresholdProvider struct {
guestMem float64
guestDisk float64
storage float64
// diskTemperature holds trigger/clear by disk type; a type it does not
// list resolves to the factory disk temperature policy.
diskTemperature map[string][2]float64
}
func (m *mockThresholdProvider) GetNodeCPUThreshold() float64 { return m.nodeCPU }
@@ -59,6 +63,15 @@ func (m *mockThresholdProvider) GetGuestCPUThreshold() float64 { return 0 }
func (m *mockThresholdProvider) GetGuestMemoryThreshold() float64 { return m.guestMem }
func (m *mockThresholdProvider) GetGuestDiskThreshold() float64 { return m.guestDisk }
func (m *mockThresholdProvider) GetStorageThreshold() float64 { return m.storage }
func (m *mockThresholdProvider) GetDiskTemperatureThreshold(diskType string) (float64, float64) {
if limits, ok := m.diskTemperature[diskType]; ok {
return limits[0], limits[1]
}
if threshold := alerts.DefaultDiskTemperatureThreshold(diskType); threshold != nil {
return threshold.Trigger, threshold.Clear
}
return 0, 0
}
type mockUnifiedResourceProvider struct {
UnifiedResourceProvider
+4
View File
@@ -66,6 +66,10 @@ type ThresholdProvider interface {
GetGuestDiskThreshold() float64
// GetStorageThreshold returns the usage alert trigger threshold for storage (0-100%)
GetStorageThreshold() float64
// GetDiskTemperatureThreshold returns the disk temperature alert trigger
// and clear values (°C) for a disk type. A zero trigger means disk
// temperature alerting is off.
GetDiskTemperatureThreshold(diskType string) (trigger, clear float64)
}
// AlertResolver provides the ability to review and resolve alerts
+21 -13
View File
@@ -1927,8 +1927,12 @@ type patrolPhysicalDiskRow struct {
sizeBytes int64
devPath, model string
health, status string
wearout, temperature int
smartEvidence []string
wearout int
// temperature.Collected is the only temperature Patrol may judge as
// heat; a retained last-known value is shown as context.
temperature tools.DiskTemperature
temperatureLimits diskTemperatureLimits
smartEvidence []string
}
type patrolPrecomputeNodeSource struct {
@@ -2461,7 +2465,7 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
health = "UNKNOWN"
}
rows = append(rows, patrolPhysicalDiskRow{
row := patrolPhysicalDiskRow{
id: r.ID,
name: name,
node: strings.TrimSpace(r.ParentName),
@@ -2472,9 +2476,11 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
health: health,
status: status,
wearout: r.PhysicalDisk.Wearout,
temperature: r.PhysicalDisk.Temperature,
temperature: tools.SplitDiskTemperature(r.PhysicalDisk.Temperature, r.PhysicalDisk.Collection),
smartEvidence: unifiedPhysicalDiskSMARTIssueParts(r.PhysicalDisk.SMART),
})
}
row.temperatureLimits = snap.diskTemperatureLimits(row.diskType)
rows = append(rows, row)
}
return rows
}
@@ -2502,7 +2508,7 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
health = "UNKNOWN"
}
rows = append(rows, patrolPhysicalDiskRow{
row := patrolPhysicalDiskRow{
id: d.ID,
name: name,
node: strings.TrimSpace(d.Node),
@@ -2513,9 +2519,11 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
health: health,
status: status,
wearout: d.Wearout,
temperature: d.Temperature,
temperature: tools.SplitDiskTemperature(d.Temperature, d.Collection),
smartEvidence: modelPhysicalDiskSMARTIssueParts(d.SmartAttributes),
})
}
row.temperatureLimits = snap.diskTemperatureLimits(row.diskType)
rows = append(rows, row)
}
return rows
}
@@ -2531,7 +2539,7 @@ func patrolPhysicalDiskHealthIssue(row patrolPhysicalDiskRow) bool {
(row.wearout == 0 &&
(strings.EqualFold(row.diskType, "ssd") || strings.EqualFold(row.diskType, "nvme")))) &&
row.wearout < 20) ||
row.temperature > 55 ||
row.temperatureLimits.hot(row.temperature.Collected) ||
len(row.smartEvidence) > 0
}
@@ -2959,8 +2967,8 @@ func (p *PatrolService) seedResourceInventoryState(snap patrolRuntimeState, scop
wear = fmt.Sprintf("%d%%", row.wearout)
}
temp := "—"
if row.temperature > 0 {
temp = fmt.Sprintf("%dC", row.temperature)
if text := row.temperature.Format("C"); text != "" {
temp = text
}
sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s | %s | %s |\n",
diskName, node, diskType, size, row.health, wear, temp, row.status))
@@ -3470,8 +3478,8 @@ func (p *PatrolService) seedHealthAndAlertsState(snap patrolRuntimeState, scoped
wearout = fmt.Sprintf("%d%%", row.wearout)
}
temp := "—"
if row.temperature > 0 {
temp = fmt.Sprintf("%d°C", row.temperature)
if text := row.temperature.Format("°C"); text != "" {
temp = text
}
smartEvidence := "—"
if len(row.smartEvidence) > 0 {
+66
View File
@@ -17,6 +17,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
)
type noExecutorChatService struct{}
@@ -1054,6 +1055,71 @@ func TestPatrolPhysicalDiskHealthIssueDistinguishesKnownZeroWearout(t *testing.T
}
}
// A retained last-known temperature (a disk in standby, a host agent past its
// reporting lease) is context, not heat. Patrol's disk issue gate, triage flags
// and finding verification judge only a collected reading, and both seed
// tables label the retained value as last known.
func TestPatrolPhysicalDiskRowsJudgeHeatOnCollectedTemperature(t *testing.T) {
standby := &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}
collected := &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")}
unified := newPatrolRuntimeState(models.StateSnapshot{})
unified.unifiedResourceProvider = &mockUnifiedResourceProvider{
getByTypeFunc: func(t unifiedresources.ResourceType) []unifiedresources.Resource {
if t != unifiedresources.ResourceTypePhysicalDisk {
return nil
}
return []unifiedresources.Resource{
{ID: "disk-standby", Name: "standby", Type: unifiedresources.ResourceTypePhysicalDisk, Status: unifiedresources.StatusOnline, ParentName: "tower",
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{DevPath: "/dev/sda", Model: "WDC WD80EFAX", DiskType: "sata", Health: "PASSED", Wearout: -1, Temperature: 61, Collection: standby}},
{ID: "disk-hot", Name: "hot", Type: unifiedresources.ResourceTypePhysicalDisk, Status: unifiedresources.StatusOnline, ParentName: "tower",
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{DevPath: "/dev/sdb", Model: "WDC WD80EFAX", DiskType: "sata", Health: "PASSED", Wearout: -1, Temperature: 58, Collection: collected}},
}
},
}
legacy := newPatrolRuntimeState(models.StateSnapshot{PhysicalDisks: []models.PhysicalDisk{
{ID: "disk-standby", Node: "tower", DevPath: "/dev/sda", Model: "WDC WD80EFAX", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 61, Collection: standby},
{ID: "disk-hot", Node: "tower", DevPath: "/dev/sdb", Model: "WDC WD80EFAX", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 58, Collection: collected},
}})
for name, snap := range map[string]patrolRuntimeState{"unified": unified, "state": legacy} {
t.Run(name, func(t *testing.T) {
byID := map[string]patrolPhysicalDiskRow{}
for _, row := range patrolPhysicalDiskRows(snap, nil) {
byID[row.id] = row
}
if patrolPhysicalDiskHealthIssue(byID["disk-standby"]) {
t.Fatalf("a standby disk's retained 61C is not a health issue: %+v", byID["disk-standby"])
}
if !patrolPhysicalDiskHealthIssue(byID["disk-hot"]) {
t.Fatalf("a collected 58C is a health issue: %+v", byID["disk-hot"])
}
flags := triageDiskHealthChecksState(snap, nil)
if len(flags) != 1 || !strings.Contains(flags[0].Reason, "Disk temperature 58") {
t.Fatalf("triage flags = %+v, want only the collected 58C disk", flags)
}
if verification, ok := patrolLookupPhysicalDiskVerificationState(snap, "disk-standby"); !ok || verification.temperature != 0 {
t.Fatalf("standby disk verification = %+v (found %v), want no temperature", verification, ok)
}
})
}
ps := NewPatrolService(nil, nil)
cfg := DefaultPatrolConfig()
now := time.Now()
health := ps.seedHealthAndAlertsState(unified, nil, cfg, now)
for _, part := range []string{"| last known 61°C (disk is in standby) |", "| 58°C |"} {
if !strings.Contains(health, part) {
t.Fatalf("disk health table missing %q:\n%s", part, health)
}
}
inventory := ps.seedResourceInventoryState(unified, nil, cfg, now, false, nil)
for _, part := range []string{"| last known 61C (disk is in standby) |", "| 58C |"} {
if !strings.Contains(inventory, part) {
t.Fatalf("physical disk inventory missing %q:\n%s", part, inventory)
}
}
}
func TestSeedHealthAndAlerts_WithIssues(t *testing.T) {
ps := NewPatrolService(nil, nil)
cfg := DefaultPatrolConfig()
+50
View File
@@ -0,0 +1,50 @@
package ai
import "github.com/rcourtman/pulse-go-rewrite/internal/alerts"
// diskTemperatureLimits is the alert disk temperature policy for one disk
// type, the same policy disk temperature alerts and the Physical Disks Health
// verdict judge heat by. A disk is hot at the trigger and stays hot until it
// cools to the clear value. A zero trigger means disk temperature alerting is
// off, so no reading counts as heat.
type diskTemperatureLimits struct {
trigger float64
clear float64
}
// diskTemperatureLimitsFor resolves the policy from the user's alert
// configuration, or from the factory alert configuration when Patrol has no
// threshold provider.
func diskTemperatureLimitsFor(provider ThresholdProvider, diskType string) diskTemperatureLimits {
var trigger, clear float64
if provider != nil {
trigger, clear = provider.GetDiskTemperatureThreshold(diskType)
} else if threshold := alerts.DefaultDiskTemperatureThreshold(diskType); threshold != nil {
trigger, clear = threshold.Trigger, threshold.Clear
}
if trigger <= 0 {
return diskTemperatureLimits{}
}
if clear <= 0 || clear > trigger {
clear = trigger
}
return diskTemperatureLimits{trigger: trigger, clear: clear}
}
// hot reports a reading at or above the alert trigger.
func (l diskTemperatureLimits) hot(temperature int) bool {
return l.trigger > 0 && temperature > 0 && float64(temperature) >= l.trigger
}
// cooled reports a reading at or below the clear value, where a disk
// temperature alert recovers. With no band below the trigger, a reading at
// the trigger still fires, so only a reading under it has cooled.
func (l diskTemperatureLimits) cooled(temperature int) bool {
if l.trigger <= 0 || temperature <= 0 {
return true
}
if l.clear >= l.trigger {
return float64(temperature) < l.trigger
}
return float64(temperature) <= l.clear
}
+249
View File
@@ -0,0 +1,249 @@
package ai
import (
"errors"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
)
// raisedNVMeThresholds is a user who raised the NVMe disk temperature trigger
// to 75C (clear 70) in Alerts and left every other type at its default.
func raisedNVMeThresholds() *mockThresholdProvider {
return &mockThresholdProvider{diskTemperature: map[string][2]float64{"nvme": {75, 70}}}
}
func TestDiskTemperatureLimitsFollowTheAlertPolicy(t *testing.T) {
for _, tc := range []struct {
name string
provider ThresholdProvider
diskType string
want diskTemperatureLimits
}{
{name: "factory nvme", diskType: "nvme", want: diskTemperatureLimits{trigger: 70, clear: 65}},
{name: "factory sata", diskType: "sata", want: diskTemperatureLimits{trigger: 55, clear: 50}},
{name: "factory untyped", diskType: "", want: diskTemperatureLimits{trigger: 55, clear: 50}},
{name: "raised nvme", provider: raisedNVMeThresholds(), diskType: "nvme", want: diskTemperatureLimits{trigger: 75, clear: 70}},
{name: "raised nvme leaves sata", provider: raisedNVMeThresholds(), diskType: "sata", want: diskTemperatureLimits{trigger: 55, clear: 50}},
{name: "alerting off", provider: &mockThresholdProvider{diskTemperature: map[string][2]float64{"sata": {0, 0}}}, diskType: "sata", want: diskTemperatureLimits{}},
{name: "clear above trigger", provider: &mockThresholdProvider{diskTemperature: map[string][2]float64{"sas": {60, 64}}}, diskType: "sas", want: diskTemperatureLimits{trigger: 60, clear: 60}},
} {
if got := diskTemperatureLimitsFor(tc.provider, tc.diskType); got != tc.want {
t.Errorf("%s: limits = %+v, want %+v", tc.name, got, tc.want)
}
}
off := diskTemperatureLimits{}
if off.hot(95) || !off.cooled(95) {
t.Fatal("with disk temperature alerting off no reading is heat")
}
}
func diskTemperatureTriageState(provider ThresholdProvider) patrolRuntimeState {
state := newPatrolRuntimeState(models.StateSnapshot{PhysicalDisks: []models.PhysicalDisk{
{ID: "nvme-63", DevPath: "/dev/nvme0n1", Type: "nvme", Health: "PASSED", Wearout: 90, Temperature: 63},
{ID: "nvme-66", DevPath: "/dev/nvme1n1", Type: "nvme", Health: "PASSED", Wearout: 90, Temperature: 66},
{ID: "nvme-72", DevPath: "/dev/nvme2n1", Type: "nvme", Health: "PASSED", Wearout: 90, Temperature: 72},
{ID: "sata-52", DevPath: "/dev/sda", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 52},
{ID: "sata-56", DevPath: "/dev/sdb", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 56},
}})
state.thresholdProvider = provider
return state
}
// Patrol judges disk heat by the alert disk temperature policy: a warning from
// the per-type trigger and nothing below it, the band under the trigger being
// a Temp cell colour rather than a finding. An NVMe at 63C is not hot; a SATA
// disk at 56C is.
func TestTriageDiskTemperatureFollowsTheAlertPolicy(t *testing.T) {
temperatureFlag := func(flags []TriageFlag, id string) *TriageFlag {
return triageFindFlag(flags, func(f TriageFlag) bool {
return f.ResourceID == id && strings.Contains(f.Reason, "Disk temperature")
})
}
expect := func(flags []TriageFlag, id, severity string, threshold float64) {
t.Helper()
flag := temperatureFlag(flags, id)
if severity == "" {
if flag != nil {
t.Errorf("%s: unexpected temperature flag %+v", id, *flag)
}
return
}
if flag == nil || flag.Severity != severity || flag.Threshold != threshold {
t.Errorf("%s: temperature flag = %+v, want %s at threshold %.0f", id, flag, severity, threshold)
}
}
flags := triageDiskHealthChecksState(diskTemperatureTriageState(nil), nil)
expect(flags, "nvme-63", "", 0)
expect(flags, "nvme-66", "", 0)
expect(flags, "nvme-72", "warning", 70)
expect(flags, "sata-52", "", 0)
expect(flags, "sata-56", "warning", 55)
if flag := temperatureFlag(flags, "sata-56"); flag == nil || flag.Reason != "Disk temperature 56°C (alert threshold: 55°C)" {
t.Errorf("sata-56 reason = %+v, want the reading and the alert trigger", flag)
}
flags = triageDiskHealthChecksState(diskTemperatureTriageState(raisedNVMeThresholds()), nil)
expect(flags, "nvme-72", "", 0)
expect(flags, "sata-56", "warning", 55)
off := &mockThresholdProvider{diskTemperature: map[string][2]float64{"nvme": {0, 0}, "sata": {0, 0}}}
if flags := triageDiskHealthChecksState(diskTemperatureTriageState(off), nil); len(flags) != 0 {
t.Fatalf("disk temperature alerting off should raise no heat flags, got %+v", flags)
}
}
// The seed's disk issue list and finding verification share the policy: a
// disk is an issue from the trigger and recovers below the clear value.
func TestPatrolDiskHeatIssueAndRecoveryFollowTheAlertPolicy(t *testing.T) {
issues := map[string]bool{}
for _, row := range patrolPhysicalDiskRows(diskTemperatureTriageState(nil), nil) {
issues[row.id] = patrolPhysicalDiskHealthIssue(row)
}
for id, want := range map[string]bool{"nvme-63": false, "nvme-66": false, "nvme-72": true, "sata-52": false, "sata-56": true} {
if issues[id] != want {
t.Errorf("%s: health issue = %v, want %v", id, issues[id], want)
}
}
for _, row := range patrolPhysicalDiskRows(diskTemperatureTriageState(raisedNVMeThresholds()), nil) {
if row.id == "nvme-72" && patrolPhysicalDiskHealthIssue(row) {
t.Error("nvme-72 under a raised 75C trigger should not be an issue")
}
}
state := diskTemperatureTriageState(nil)
for id, want := range map[string]bool{"nvme-63": true, "nvme-66": false, "nvme-72": false, "sata-52": false} {
recovered, err := verifyMetricRecoveredState(state, PatrolThresholds{}, "disk-high", id, "physical_disk")
if err != nil {
t.Fatalf("%s: verification error %v", id, err)
}
if recovered != want {
t.Errorf("%s: recovered = %v, want %v", id, recovered, want)
}
}
}
func TestResourceContextListsDisksHotByTheAlertPolicy(t *testing.T) {
registry := unifiedresources.NewRegistry(nil)
now := time.Now()
registry.IngestSnapshot(models.StateSnapshot{PhysicalDisks: []models.PhysicalDisk{
{ID: "pve-nvme", Node: "pve", Instance: "lab", DevPath: "/dev/nvme0n1", Model: "Samsung 980", Serial: "N63", Type: "nvme", Health: "PASSED", Wearout: 90, Temperature: 63, LastChecked: now},
{ID: "pve-nvme-72", Node: "pve", Instance: "lab", DevPath: "/dev/nvme1n1", Model: "Samsung 990", Serial: "N72", Type: "nvme", Health: "PASSED", Wearout: 90, Temperature: 72, LastChecked: now},
{ID: "pve-sata", Node: "pve", Instance: "lab", DevPath: "/dev/sda", Model: "WDC WD80EFAX", Serial: "S56", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 56, LastChecked: now},
}})
s := &Service{}
s.SetUnifiedResourceProvider(unifiedresources.NewUnifiedAIAdapter(registry))
attention := func() string {
got := s.buildUnifiedResourceContext()
_, section, found := strings.Cut(got, "Physical Disks Needing Attention")
if !found {
return ""
}
section, _, _ = strings.Cut(section, "\n\n")
return section
}
// Disk names are redacted in this context, so the readings identify them.
section := attention()
if !strings.Contains(section, "Temp: 56C") || !strings.Contains(section, "Temp: 72C") || strings.Contains(section, "Temp: 63C") {
t.Fatalf("factory policy: attention section = %q, want the SATA 56C and NVMe 72C disks only", section)
}
s.SetPatrolThresholdProvider(raisedNVMeThresholds())
section = attention()
if !strings.Contains(section, "Temp: 56C") || strings.Contains(section, "Temp: 72C") || strings.Contains(section, "Temp: 63C") {
t.Fatalf("raised NVMe trigger: attention section = %q, want only the SATA 56C disk", section)
}
}
// A Patrol run judges disk heat by the thresholds the user configured, through
// both ways a run builds its state.
func TestPatrolRunStateCarriesTheConfiguredDiskTemperaturePolicy(t *testing.T) {
snapshot := models.StateSnapshot{PhysicalDisks: []models.PhysicalDisk{
{ID: "nvme-72", DevPath: "/dev/nvme0n1", Type: "nvme", Health: "PASSED", Wearout: 90, Temperature: 72},
}}
ps := NewPatrolService(nil, &mockStateProvider{state: snapshot})
if got := ps.currentPatrolRuntimeState().diskTemperatureLimits("nvme"); got.trigger != 70 {
t.Fatalf("no provider: nvme trigger = %v, want the factory 70", got.trigger)
}
ps.SetThresholdProvider(raisedNVMeThresholds())
for name, state := range map[string]patrolRuntimeState{
"current": ps.currentPatrolRuntimeState(),
"snapshot": ps.patrolRuntimeStateForSnapshot(snapshot),
} {
if got := state.diskTemperatureLimits("nvme"); got.trigger != 75 || got.clear != 70 {
t.Errorf("%s state: nvme limits = %+v, want the configured 75/70", name, got)
}
if flags := triageDiskHealthChecksState(state, nil); len(flags) != 0 {
t.Errorf("%s state: nvme at 72C under a 75C trigger flagged %+v", name, flags)
}
}
}
// A scoped Patrol run filters its state; the filter must keep the configured
// thresholds, or a scoped run would judge heat by the factory triggers.
func TestScopedPatrolStateKeepsTheConfiguredDiskTemperaturePolicy(t *testing.T) {
state := diskTemperatureTriageState(raisedNVMeThresholds())
scoped := filterPatrolStateByScopeState(state, PatrolScope{ResourceIDs: []string{"nvme-72"}})
if got := scoped.diskTemperatureLimits("nvme"); got.trigger != 75 || got.clear != 70 {
t.Fatalf("scoped state nvme limits = %+v, want the configured 75/70", got)
}
if flags := triageDiskHealthChecksState(scoped, nil); len(flags) != 0 {
t.Fatalf("scoped run flagged nvme at 72C under a 75C trigger: %+v", flags)
}
}
// The alert threshold provider can arrive before Patrol exists; Patrol must
// still start with it.
func TestPatrolStartsWithAThresholdProviderSetBeforeIt(t *testing.T) {
s := NewService(nil, nil)
s.SetPatrolThresholdProvider(raisedNVMeThresholds())
s.SetStateProvider(&mockStateProvider{})
patrol := s.GetPatrolService()
if patrol == nil {
t.Fatal("expected Patrol to start once a state provider exists")
}
if got := patrol.currentPatrolRuntimeState().diskTemperatureLimits("nvme"); got.trigger != 75 {
t.Fatalf("Patrol nvme trigger = %v, want the provider's 75", got.trigger)
}
}
// A heat finding recovers at the clear value, where its alert recovers.
func TestDiskHeatRecoversAtTheClearValue(t *testing.T) {
limits := diskTemperatureLimitsFor(nil, "nvme")
if !limits.cooled(65) || limits.cooled(66) {
t.Fatalf("nvme limits %+v: want 65C cooled and 66C still warm", limits)
}
// With no band below the trigger, a reading at the trigger is still hot.
noBand := diskTemperatureLimitsFor(&mockThresholdProvider{diskTemperature: map[string][2]float64{"sas": {70, 70}}}, "sas")
if !noBand.hot(70) || noBand.cooled(70) || !noBand.cooled(69) {
t.Fatalf("no-band limits %+v: want 70C hot and not cooled, 69C cooled", noBand)
}
}
// With no current reading (standby, a silent agent) a disk last seen hot has
// not shown that it cooled, so heat recovery is unknown rather than proven.
func TestDiskHeatRecoveryNeedsACurrentReading(t *testing.T) {
retained := &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}
state := newPatrolRuntimeState(models.StateSnapshot{PhysicalDisks: []models.PhysicalDisk{
{ID: "sata-retained-hot", DevPath: "/dev/sda", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 56, Collection: retained},
{ID: "sata-retained-cool", DevPath: "/dev/sdb", Type: "sata", Health: "PASSED", Wearout: -1, Temperature: 40, Collection: retained},
}})
if _, err := verifyMetricRecoveredState(state, PatrolThresholds{}, "disk-high", "sata-retained-hot", "physical_disk"); !errors.Is(err, aicontracts.ErrVerificationUnknown) {
t.Fatalf("retained 56C: err = %v, want ErrVerificationUnknown", err)
}
recovered, err := verifyMetricRecoveredState(state, PatrolThresholds{}, "disk-high", "sata-retained-cool", "physical_disk")
if err != nil || !recovered {
t.Fatalf("retained 40C: recovered = %v, err = %v, want recovered", recovered, err)
}
}
+17 -4
View File
@@ -2291,9 +2291,16 @@ func verifyMetricRecoveredState(snap patrolRuntimeState, thresholds PatrolThresh
if disk.wearout >= 0 && disk.wearout < 20 {
return false, nil
}
if disk.temperature > 55 {
// A hot disk recovers once it cools to the alert clear
// value, as its disk temperature alert does.
if !disk.temperatureLimits.cooled(disk.temperature) {
return false, nil
}
// A disk last seen hot with no current reading has not
// shown that it cooled, so its recovery is unknown.
if disk.temperature <= 0 && !disk.temperatureLimits.cooled(disk.lastKnownTemperature) {
return false, fmt.Errorf("%w: no current temperature reading for %s", aicontracts.ErrVerificationUnknown, rid)
}
return true, nil
}
break
@@ -2318,6 +2325,10 @@ type patrolPhysicalDiskVerification struct {
health string
wearout int
temperature int
// lastKnownTemperature is a retained reading the current observation
// did not collect (a disk in standby, a silent agent).
lastKnownTemperature int
temperatureLimits diskTemperatureLimits
}
type patrolPhysicalDiskVisitor func(identifiers []string, verification patrolPhysicalDiskVerification) bool
@@ -2461,9 +2472,11 @@ func patrolVisitPhysicalDiskVerification(snap patrolRuntimeState, visit patrolPh
rows := patrolPhysicalDiskRows(snap, nil)
for _, disk := range rows {
if !visit([]string{disk.id, disk.name, disk.devPath, disk.model}, patrolPhysicalDiskVerification{
health: strings.TrimSpace(disk.health),
wearout: disk.wearout,
temperature: disk.temperature,
health: strings.TrimSpace(disk.health),
wearout: disk.wearout,
temperature: disk.temperature.Collected,
lastKnownTemperature: disk.temperature.LastKnown,
temperatureLimits: disk.temperatureLimits,
}) {
return true
}
+1
View File
@@ -1464,6 +1464,7 @@ func newPatrolScopedFilterState(snap patrolRuntimeState) patrolScopedFilterState
filtered: patrolRuntimeState{
readState: snap.readState,
unifiedResourceProvider: snap.unifiedResourceProvider,
thresholdProvider: snap.thresholdProvider,
},
includedResourceIDs: make(map[string]bool),
includedGuestVMIDs: make(map[int]bool),
+20 -3
View File
@@ -89,6 +89,15 @@ type patrolRuntimeState struct {
ConnectionHealth map[string]bool
ActiveAlerts []models.Alert
RecentlyResolved []models.ResolvedAlert
// thresholdProvider carries the user's alert thresholds into the run. Nil
// means the factory alert configuration.
thresholdProvider ThresholdProvider
}
// diskTemperatureLimits resolves the alert disk temperature policy for one
// disk type.
func (s patrolRuntimeState) diskTemperatureLimits(diskType string) diskTemperatureLimits {
return diskTemperatureLimitsFor(s.thresholdProvider, diskType)
}
func newPatrolRuntimeState(snapshot models.StateSnapshot) patrolRuntimeState {
@@ -171,11 +180,16 @@ func (p *PatrolService) currentPatrolRuntimeState() patrolRuntimeState {
stateProvider := p.stateProvider
readState := p.readState
unifiedResourceProvider := p.unifiedResourceProvider
thresholdProvider := p.thresholdProvider
p.mu.RUnlock()
var state patrolRuntimeState
if stateProvider == nil {
return emptyPatrolRuntimeState(readState, unifiedResourceProvider)
state = emptyPatrolRuntimeState(readState, unifiedResourceProvider)
} else {
state = newPatrolRuntimeStateWithProviders(stateProvider.ReadSnapshot(), readState, unifiedResourceProvider)
}
return newPatrolRuntimeStateWithProviders(stateProvider.ReadSnapshot(), readState, unifiedResourceProvider)
state.thresholdProvider = thresholdProvider
return state
}
func (p *PatrolService) hasPatrolRuntimeInputs() bool {
@@ -194,8 +208,11 @@ func (p *PatrolService) patrolRuntimeStateForSnapshot(snapshot models.StateSnaps
p.mu.RLock()
readState := p.readState
unifiedResourceProvider := p.unifiedResourceProvider
thresholdProvider := p.thresholdProvider
p.mu.RUnlock()
return newPatrolRuntimeStateWithProviders(snapshot, readState, unifiedResourceProvider)
state := newPatrolRuntimeStateWithProviders(snapshot, readState, unifiedResourceProvider)
state.thresholdProvider = thresholdProvider
return state
}
func patrolVisitRuntimeResources(s patrolRuntimeState, visit func(patrolRuntimeResourceRecord) bool) {
+3
View File
@@ -20,6 +20,9 @@ func (m MockThresholdProvider) GetNodeMemoryThreshold() float64 { return m.Node
func (m MockThresholdProvider) GetGuestMemoryThreshold() float64 { return m.GuestMem }
func (m MockThresholdProvider) GetGuestDiskThreshold() float64 { return m.GuestDisk }
func (m MockThresholdProvider) GetStorageThreshold() float64 { return m.Storage }
func (m MockThresholdProvider) GetDiskTemperatureThreshold(string) (float64, float64) {
return 0, 0
}
func TestCalculatePatrolThresholds_Default(t *testing.T) {
// Test default behavior (exact mode)
+8 -5
View File
@@ -345,13 +345,16 @@ func triageDiskHealthChecksState(snap patrolRuntimeState, scopedSet map[string]b
flags := make([]TriageFlag, 0)
for _, disk := range patrolPhysicalDiskRows(snap, scopedSet) {
flags = append(flags, triagePhysicalDiskFlags(disk.id, disk.name, disk.health, disk.wearout, disk.temperature)...)
flags = append(flags, triagePhysicalDiskFlags(disk.id, disk.name, disk.health, disk.wearout, disk.temperature.Collected, disk.temperatureLimits)...)
}
return flags
}
func triagePhysicalDiskFlags(resourceID, resourceName, health string, wearout, temperature int) []TriageFlag {
// triagePhysicalDiskFlags judges heat by the alert disk temperature policy: a
// warning from the alert trigger, the reading the disk's temperature alert and
// its Running Hot verdict start at.
func triagePhysicalDiskFlags(resourceID, resourceName, health string, wearout, temperature int, temperatureLimits diskTemperatureLimits) []TriageFlag {
flags := make([]TriageFlag, 0, 3)
if health != "" && !strings.EqualFold(health, "PASSED") && !strings.EqualFold(health, "UNKNOWN") && !strings.EqualFold(health, "OK") {
flags = append(flags, TriageFlag{
@@ -377,17 +380,17 @@ func triagePhysicalDiskFlags(resourceID, resourceName, health string, wearout, t
Threshold: 20,
})
}
if temperature > 55 {
if temperatureLimits.hot(temperature) {
flags = append(flags, TriageFlag{
ResourceID: resourceID,
ResourceName: resourceName,
ResourceType: "physical_disk",
Category: "health",
Severity: "warning",
Reason: fmt.Sprintf("Disk temperature %d°C", temperature),
Reason: fmt.Sprintf("Disk temperature %d°C (alert threshold: %.0f°C)", temperature, temperatureLimits.trigger),
Metric: "disk",
Value: float64(temperature),
Threshold: 55,
Threshold: temperatureLimits.trigger,
})
}
return flags
+9 -5
View File
@@ -5,6 +5,7 @@ import (
"sort"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
@@ -51,6 +52,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
urp := s.unifiedResourceProvider
ap := s.alertProvider
agentServer := s.agentServer
thresholdProvider := s.thresholdProvider
s.mu.RUnlock()
if urp != nil {
@@ -409,12 +411,14 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
attention := make([]unifiedresources.Resource, 0)
for _, disk := range physicalDisks {
health := ""
temperature := 0
hot := false
if disk.PhysicalDisk != nil {
health = strings.ToUpper(strings.TrimSpace(disk.PhysicalDisk.Health))
temperature = disk.PhysicalDisk.Temperature
// A retained last-known reading is not current heat.
collected := tools.SplitDiskTemperature(disk.PhysicalDisk.Temperature, disk.PhysicalDisk.Collection).Collected
hot = diskTemperatureLimitsFor(thresholdProvider, disk.PhysicalDisk.DiskType).hot(collected)
}
if disk.Status != unifiedresources.StatusOnline || (health != "" && health != "PASSED" && health != "UNKNOWN") || temperature >= 50 {
if disk.Status != unifiedresources.StatusOnline || (health != "" && health != "PASSED" && health != "UNKNOWN") || hot {
attention = append(attention, disk)
}
}
@@ -431,8 +435,8 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
if value := strings.TrimSpace(disk.PhysicalDisk.Health); value != "" {
health = value
}
if disk.PhysicalDisk.Temperature > 0 {
temperature = fmt.Sprintf(", Temp: %dC", disk.PhysicalDisk.Temperature)
if text := tools.SplitDiskTemperature(disk.PhysicalDisk.Temperature, disk.PhysicalDisk.Collection).Format("C"); text != "" {
temperature = ", Temp: " + text
}
}
healthSummary := ""
+51
View File
@@ -8,6 +8,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
)
func TestBuildUnifiedResourceContext_NilProvider(t *testing.T) {
@@ -741,6 +742,56 @@ func TestBuildUnifiedResourceContextIncludesTrueNASResources(t *testing.T) {
}
}
// A retained disk temperature (a disk in standby, a host agent past its
// reporting lease) is not current heat: it must not put a disk on the
// attention list, and a disk listed for another reason shows it as last known.
func TestBuildUnifiedResourceContextJudgesDiskHeatOnCollectedTemperature(t *testing.T) {
disk := func(id string, status unifiedresources.ResourceStatus, temperature int, collection diskinventory.FieldStatus) unifiedresources.Resource {
return unifiedresources.Resource{
ID: id, Name: id, Type: unifiedresources.ResourceTypePhysicalDisk, Status: status, ParentName: "tower",
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
Health: "PASSED", Wearout: -1, Temperature: temperature,
Collection: &diskinventory.CollectionStatus{Temperature: collection},
},
}
}
disks := []unifiedresources.Resource{
disk("standby-disk", unifiedresources.StatusOnline, 58, diskinventory.Unavailable("smartctl", "disk is in standby")),
disk("silent-disk", unifiedresources.StatusOffline, 52, diskinventory.Unavailable("smartctl", "host agent stopped reporting")),
disk("hot-disk", unifiedresources.StatusOnline, 55, diskinventory.Available("smartctl")),
}
s := &Service{unifiedResourceProvider: &mockUnifiedResourceProvider{
getStatsFunc: func() unifiedresources.ResourceStats {
return unifiedresources.ResourceStats{Total: len(disks), ByType: map[unifiedresources.ResourceType]int{unifiedresources.ResourceTypePhysicalDisk: len(disks)}}
},
getByTypeFunc: func(resourceType unifiedresources.ResourceType) []unifiedresources.Resource {
if resourceType == unifiedresources.ResourceTypePhysicalDisk {
return disks
}
return nil
},
}}
got := s.buildUnifiedResourceContext()
_, attention, found := strings.Cut(got, "**Physical Disks Needing Attention:**")
if !found {
t.Fatalf("expected disk attention section, got %q", got)
}
attention, _, _ = strings.Cut(attention, "\n\n")
if entries := strings.Count(attention, "\n- "); entries != 2 {
t.Fatalf("attention entries = %d, want the offline and the collected-hot disk only: %q", entries, attention)
}
if strings.Contains(attention, "58C") {
t.Fatalf("a standby disk's retained 58C must not flag it for heat: %q", attention)
}
if !strings.Contains(attention, "(PASSED), Temp: last known 52C (host agent stopped reporting) [offline]") {
t.Fatalf("expected the offline disk's retained temperature as last known: %q", attention)
}
if !strings.Contains(attention, "(PASSED), Temp: 55C [online]") {
t.Fatalf("expected the collected 55C disk on the attention list: %q", attention)
}
}
func TestBuildUnifiedResourceContext_TruncatesLargeContext(t *testing.T) {
largeName := strings.Repeat("a", 60000)
+7 -2
View File
@@ -261,6 +261,7 @@ type Service struct {
patrolAutopilotPolicy func() unifiedresources.PatrolAutopilotServerPolicy
patrolObjectiveStore *PatrolObjectiveStore
patrolService *PatrolService // Background AI monitoring service
thresholdProvider ThresholdProvider // User-configured alert thresholds; nil means factory defaults
metadataProvider MetadataProvider // Enables AI to update resource URLs
incidentStore *memory.IncidentStore // Alert-scoped investigation memory; not the canonical durable resource history
chatService ChatServiceProvider // Chat service for investigation orchestrator
@@ -584,6 +585,9 @@ func (s *Service) initPatrolServiceLocked() {
s.patrolService = NewPatrolService(s, s.stateProvider)
s.patrolService.SetObjectiveStore(s.patrolObjectiveStore)
if s.thresholdProvider != nil {
s.patrolService.SetThresholdProvider(s.thresholdProvider)
}
if s.knowledgeStore != nil {
s.patrolService.SetKnowledgeStore(s.knowledgeStore)
}
@@ -831,9 +835,10 @@ func (s *Service) ClearCostHistory() error {
// SetPatrolThresholdProvider sets the threshold provider for patrol
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
func (s *Service) SetPatrolThresholdProvider(provider ThresholdProvider) {
s.mu.RLock()
s.mu.Lock()
s.thresholdProvider = provider
patrol := s.patrolService
s.mu.RUnlock()
s.mu.Unlock()
if patrol != nil {
patrol.SetThresholdProvider(provider)
+13 -2
View File
@@ -1222,7 +1222,12 @@ type SMARTDiskSummary struct {
Device string `json:"device"`
Model string `json:"model"`
Health string `json:"health"`
Temperature int `json:"temperature,omitempty"`
Temperature int `json:"temperature,omitempty"` // Celsius, only when collected now
// LastKnownTemperature is a temperature kept from an earlier observation
// when the current one did not collect it (a disk in standby, a host
// agent past its reporting lease); LastKnownTemperatureReason says why.
LastKnownTemperature int `json:"last_known_temperature,omitempty"`
LastKnownTemperatureReason string `json:"last_known_temperature_reason,omitempty"`
}
// RAIDArraySummary is a summarized RAID array
@@ -2045,10 +2050,16 @@ type PhysicalDiskSummary struct {
Risk *unifiedresources.PhysicalDiskRisk `json:"risk,omitempty"`
SMART *unifiedresources.SMARTMeta `json:"smart,omitempty"`
LifeRemainingPercent *int `json:"life_remaining_percent,omitempty"` // SSD life remaining percent (0-100), nil when unavailable
Temperature *int `json:"temperature,omitempty"` // Celsius, nil when unavailable
Temperature *int `json:"temperature,omitempty"` // Celsius, nil unless collected now
RPM *int `json:"rpm,omitempty"` // 0 for SSDs, nil when unavailable
Used string `json:"used,omitempty"`
LastChecked time.Time `json:"last_checked,omitempty"`
// LastKnownTemperature is a temperature kept from an earlier observation
// when the current one did not collect it (a disk in standby, a host
// agent past its reporting lease); LastKnownTemperatureReason says why.
LastKnownTemperature *int `json:"last_known_temperature,omitempty"`
LastKnownTemperatureReason string `json:"last_known_temperature_reason,omitempty"`
}
// ========== Host RAID Types ==========
+54
View File
@@ -0,0 +1,54 @@
package tools
import (
"fmt"
"strings"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
)
// DiskTemperature splits a physical disk temperature by its collection state
// for AI context. Collected is a reading the current observation took, and it
// is the only temperature that may be judged as heat. LastKnown is a value
// normalization kept without collecting it now (a disk in standby, a host
// agent past its reporting lease), with the collection state's reason in
// Reason. It is history, never evidence that the disk is hot now.
type DiskTemperature struct {
Collected int
LastKnown int
Reason string
}
// SplitDiskTemperature classifies a disk temperature with
// diskinventory.TemperatureCollected.
func SplitDiskTemperature(temperature int, collection *diskinventory.CollectionStatus) DiskTemperature {
if diskinventory.TemperatureCollected(temperature, collection) {
return DiskTemperature{Collected: temperature}
}
if temperature <= 0 {
return DiskTemperature{}
}
split := DiskTemperature{LastKnown: temperature}
if collection != nil {
split.Reason = strings.TrimSpace(collection.Temperature.Reason)
}
return split
}
// Format renders the temperature with unit for AI text: a collected reading as
// the value ("41C"), a retained one as "last known 41C (disk is in standby)",
// and "" when the disk has no temperature.
func (t DiskTemperature) Format(unit string) string {
switch {
case t.Collected > 0:
return fmt.Sprintf("%d%s", t.Collected, unit)
case t.LastKnown > 0:
text := fmt.Sprintf("last known %d%s", t.LastKnown, unit)
if t.Reason != "" {
text += " (" + t.Reason + ")"
}
return text
default:
return ""
}
}
@@ -9,6 +9,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
"github.com/stretchr/testify/require"
)
@@ -111,3 +112,106 @@ func TestPhysicalDiskEvidencePreservesCanonicalRisk(t *testing.T) {
}
}
// Normalization keeps a disk's last-known temperature when the current
// observation did not collect one (a disk in standby, a host agent past its
// reporting lease). The tools must not hand that value to the model as the
// disk's temperature: it moves to last_known_temperature with its reason.
func TestDiskToolsReportRetainedTemperatureAsLastKnown(t *testing.T) {
standby := &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}
silent := &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "host agent stopped reporting")}
collected := &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")}
resources := []unifiedresources.Resource{
{ID: "disk-standby", Name: "Standby disk", Type: unifiedresources.ResourceTypePhysicalDisk,
Status: unifiedresources.StatusOnline, ParentName: "node-one",
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{Health: "PASSED", Wearout: -1, Temperature: 61, Collection: standby}},
{ID: "disk-live", Name: "Live disk", Type: unifiedresources.ResourceTypePhysicalDisk,
Status: unifiedresources.StatusOnline, ParentName: "node-one",
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{Health: "PASSED", Wearout: -1, Temperature: 38, Collection: collected}},
}
diskProvider := &mockDiskHealthProvider{}
diskProvider.On("GetHosts").Return([]*unifiedresources.HostView{
newHostView("host-tower", "tower", "agent-tower", "tower", &unifiedresources.HostSensorMeta{
SMART: []unifiedresources.HostSMARTMeta{
{Device: "/dev/sda", Model: "WDC WD80EFAX", Health: "PASSED", Temperature: 58, Collection: silent},
{Device: "/dev/sdb", Model: "WDC WD80EFAX", Health: "PASSED", Temperature: 36, Collection: collected},
},
}, nil, nil),
})
executor := NewPulseToolExecutor(ExecutorConfig{
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
UnifiedResourceProvider: &stubUnifiedResourceProvider{resources: resources},
DiskHealthProvider: diskProvider,
ControlLevel: ControlLevelReadOnly,
})
for _, tool := range []struct {
name string
args map[string]interface{}
key string
}{
{"pulse_metrics", map[string]interface{}{"type": "disks"}, "disks"},
{"pulse_query", map[string]interface{}{"action": "list", "type": "physical-disks"}, "physical_disks"},
} {
t.Run(tool.name, func(t *testing.T) {
result, err := executor.ExecuteTool(context.Background(), tool.name, tool.args)
require.NoError(t, err)
require.False(t, result.IsError, "%+v", result.Content)
var payload map[string]json.RawMessage
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &payload))
var disks []PhysicalDiskSummary
require.NoError(t, json.Unmarshal(payload[tool.key], &disks))
byID := map[string]PhysicalDiskSummary{}
for _, disk := range disks {
byID[disk.ID] = disk
}
retained := byID["disk-standby"]
require.Nil(t, retained.Temperature, "a retained reading is not the disk's temperature")
require.NotNil(t, retained.LastKnownTemperature)
require.Equal(t, 61, *retained.LastKnownTemperature)
require.Equal(t, "disk is in standby", retained.LastKnownTemperatureReason)
live := byID["disk-live"]
require.NotNil(t, live.Temperature)
require.Equal(t, 38, *live.Temperature)
require.Nil(t, live.LastKnownTemperature)
require.NotContains(t, string(payload[tool.key]), `"temperature":61`)
})
}
t.Run("pulse_storage disk_health", func(t *testing.T) {
result, err := executor.ExecuteTool(context.Background(), "pulse_storage", map[string]interface{}{"type": "disk_health"})
require.NoError(t, err)
require.False(t, result.IsError, "%+v", result.Content)
var response DiskHealthResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &response))
require.Len(t, response.Hosts, 1)
require.Len(t, response.Hosts[0].SMART, 2)
silentDisk, liveDisk := response.Hosts[0].SMART[0], response.Hosts[0].SMART[1]
require.Zero(t, silentDisk.Temperature)
require.Equal(t, 58, silentDisk.LastKnownTemperature)
require.Equal(t, "host agent stopped reporting", silentDisk.LastKnownTemperatureReason)
require.Equal(t, 36, liveDisk.Temperature)
require.Zero(t, liveDisk.LastKnownTemperature)
require.NotContains(t, result.Content[0].Text, `"temperature":58`)
})
}
func TestDiskTemperatureFormatsRetainedReadingAsLastKnown(t *testing.T) {
cases := []struct {
temperature int
collection *diskinventory.CollectionStatus
want string
}{
{41, nil, "41C"},
{41, &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")}, "41C"},
{41, &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}, "last known 41C (disk is in standby)"},
{41, &diskinventory.CollectionStatus{Temperature: diskinventory.Missing("smartctl", "")}, "last known 41C"},
{0, &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}, ""},
{0, nil, ""},
}
for _, tc := range cases {
split := SplitDiskTemperature(tc.temperature, tc.collection)
require.Equal(t, tc.want, split.Format("C"), "%d %+v", tc.temperature, tc.collection)
require.Equal(t, diskinventory.TemperatureCollected(tc.temperature, tc.collection), split.Collected > 0)
}
}
+6 -3
View File
@@ -3583,9 +3583,12 @@ func physicalDiskSummaryFromResource(resource unifiedresources.Resource) Physica
wearout := pd.Wearout
summary.LifeRemainingPercent = &wearout
}
if pd.Temperature > 0 {
temp := pd.Temperature
summary.Temperature = &temp
temperature := SplitDiskTemperature(pd.Temperature, pd.Collection)
if temperature.Collected > 0 {
summary.Temperature = &temperature.Collected
} else if temperature.LastKnown > 0 {
summary.LastKnownTemperature = &temperature.LastKnown
summary.LastKnownTemperatureReason = temperature.Reason
}
if pd.RPM > 0 {
rpm := pd.RPM
+7 -4
View File
@@ -402,11 +402,14 @@ func (e *PulseToolExecutor) executeGetDiskHealth(_ context.Context, _ map[string
// SMART data
if sensors := host.Sensors(); sensors != nil {
for _, disk := range sensors.SMART {
temperature := SplitDiskTemperature(disk.Temperature, disk.Collection)
hostHealth.SMART = append(hostHealth.SMART, SMARTDiskSummary{
Device: disk.Device,
Model: disk.Model,
Health: disk.Health,
Temperature: disk.Temperature,
Device: disk.Device,
Model: disk.Model,
Health: disk.Health,
Temperature: temperature.Collected,
LastKnownTemperature: temperature.LastKnown,
LastKnownTemperatureReason: temperature.Reason,
})
}
}
+33 -6
View File
@@ -990,21 +990,50 @@ func hostDiskTemperatureResourceID(hostID, device string) string {
}
// hostDiskTemperatureThresholdNoLock returns the threshold for one SMART disk
// given the host's resolved disk temperature threshold. An enabled threshold
// gives way to the disk type's DiskTempByType entry unless an explicit host or
// linked-resource override set it. Callers must hold m.mu.
// given the host's resolved disk temperature threshold. Callers must hold m.mu.
func (m *Manager) hostDiskTemperatureThresholdNoLock(hostThreshold *HysteresisThreshold, overridden bool, diskType string) *HysteresisThreshold {
return diskTemperatureThresholdForType(m.config.DiskTempByType, hostThreshold, overridden, diskType)
}
// diskTemperatureThresholdForType is the disk temperature policy. An enabled
// host threshold gives way to the disk type's DiskTempByType entry unless an
// explicit host or linked-resource override set it; a disabled one switches
// disk temperature alerting off for every type.
func diskTemperatureThresholdForType(byType map[string]HysteresisThreshold, hostThreshold *HysteresisThreshold, overridden bool, diskType string) *HysteresisThreshold {
if hostThreshold == nil || hostThreshold.Trigger <= 0 || overridden {
return hostThreshold
}
if diskType = strings.ToLower(strings.TrimSpace(diskType)); diskType != "" {
if th, ok := m.config.DiskTempByType[diskType]; ok {
if th, ok := byType[diskType]; ok {
return &th
}
}
return hostThreshold
}
// DiskTemperatureThreshold returns the threshold that judges a physical disk's
// temperature when no host override applies: the disk type's DiskTempByType
// entry, else the agent default. This is the canonical disk heat policy. Disk
// temperature alerts, Patrol and the Physical Disks Health verdict all judge
// heat against it; disk risk does not. The disk is hot at the trigger, and the
// clear value is where it stops being hot. A nil threshold or a non-positive
// trigger means disk temperature alerting is off.
func (m *Manager) DiskTemperatureThreshold(diskType string) *HysteresisThreshold {
if m == nil {
return DefaultDiskTemperatureThreshold(diskType)
}
m.mu.RLock()
defer m.mu.RUnlock()
return cloneThreshold(m.hostDiskTemperatureThresholdNoLock(m.config.AgentDefaults.DiskTemperature, false, diskType))
}
// DefaultDiskTemperatureThreshold is DiskTemperatureThreshold under the
// factory alert configuration, for callers with no alert manager.
func DefaultDiskTemperatureThreshold(diskType string) *HysteresisThreshold {
config := defaultAlertConfig()
return cloneThreshold(diskTemperatureThresholdForType(config.DiskTempByType, config.AgentDefaults.DiskTemperature, false, diskType))
}
// lowestHostDiskTemperatureThresholdNoLock returns the lowest enabled
// threshold any SMART disk of the host can be evaluated against. An alert
// whose disk type is unknown is judged against it, so a config save never
@@ -1547,8 +1576,6 @@ func splitSMARTAlertReasons(reasons []storagehealth.Reason) ([]storagehealth.Rea
switch reason.Code {
case "wearout_low", "nvme_available_spare_low", "nvme_percentage_used_high":
wearReasons = append(wearReasons, reason)
case "temperature_high":
continue
default:
healthReasons = append(healthReasons, reason)
}
@@ -1387,3 +1387,61 @@ func TestCheckMetricResolveRemovesCanonicallyKeyedAlert(t *testing.T) {
t.Fatal("expected a recently-resolved entry for the cleared alert")
}
}
// DiskTemperatureThreshold is the policy Patrol and the Physical Disks Health
// verdict judge disk heat by, so it must resolve every disk type exactly as
// CheckHost does: the per-type trigger the user set, else the agent default,
// and nothing at all once the agent default is switched off.
func TestDiskTemperatureThresholdMatchesCheckHostPolicy(t *testing.T) {
m := configureDiskTempTypeHostManager(t)
for diskType, want := range map[string]HysteresisThreshold{
"nvme": {Trigger: 70, Clear: 65},
" NVMe": {Trigger: 70, Clear: 65},
"sas": {Trigger: 65, Clear: 60},
"sata": {Trigger: 55, Clear: 50},
"hdd": {Trigger: 55, Clear: 50},
"": {Trigger: 55, Clear: 50},
} {
if got := m.DiskTemperatureThreshold(diskType); got == nil || *got != want {
t.Errorf("DiskTemperatureThreshold(%q) = %+v, want %+v", diskType, got, want)
}
if got := DefaultDiskTemperatureThreshold(diskType); got == nil || *got != want {
t.Errorf("DefaultDiskTemperatureThreshold(%q) = %+v, want the factory %+v", diskType, got, want)
}
}
// A user who raised the NVMe trigger to 75 gets no alert at 72C, and the
// policy says the same disk is not hot.
m.mu.Lock()
m.config.DiskTempByType["nvme"] = HysteresisThreshold{Trigger: 75, Clear: 70}
m.mu.Unlock()
if got := m.DiskTemperatureThreshold("nvme"); got == nil || got.Trigger != 75 || got.Clear != 70 {
t.Fatalf("raised nvme threshold = %+v, want 75/70", got)
}
host := hostWithSMARTDiskTemp("host-temp-raised-nvme", "nvme", 72)
m.CheckHost(host)
if _, exists := testLookupActiveAlert(t, m, hostDiskTempAlertID(host)); exists {
t.Fatalf("expected no alert for nvme at 72C under a raised 75C trigger, active: %v", alertKeys(m))
}
// The returned threshold is a copy.
m.DiskTemperatureThreshold("nvme").Trigger = 1
if got := m.DiskTemperatureThreshold("nvme"); got.Trigger != 75 {
t.Fatalf("caller mutation reached the config: %+v", got)
}
m.mu.Lock()
m.config.AgentDefaults.DiskTemperature = &HysteresisThreshold{Trigger: 0, Clear: 0}
m.mu.Unlock()
for _, diskType := range []string{"nvme", "sata", "hdd"} {
if got := m.DiskTemperatureThreshold(diskType); got == nil || got.Trigger > 0 {
t.Errorf("DiskTemperatureThreshold(%q) with disk temperature alerting off = %+v, want a disabled threshold", diskType, got)
}
}
var nilManager *Manager
if got := nilManager.DiskTemperatureThreshold("nvme"); got == nil || got.Trigger != 70 {
t.Fatalf("nil manager threshold = %+v, want the factory nvme 70", got)
}
}
@@ -67,6 +67,9 @@ func (concurrencyThresholdProvider) GetGuestMemoryThreshold() float64 {
}
func (concurrencyThresholdProvider) GetGuestDiskThreshold() float64 { return 95 }
func (concurrencyThresholdProvider) GetStorageThreshold() float64 { return 92 }
func (concurrencyThresholdProvider) GetDiskTemperatureThreshold(string) (float64, float64) {
return 70, 65
}
type concurrencyMetricsHistoryProvider struct{}
+3
View File
@@ -154,6 +154,9 @@ func (stubThresholdProvider) GetGuestMemoryThreshold() float64 {
}
func (stubThresholdProvider) GetGuestDiskThreshold() float64 { return 95 }
func (stubThresholdProvider) GetStorageThreshold() float64 { return 92 }
func (stubThresholdProvider) GetDiskTemperatureThreshold(string) (float64, float64) {
return 70, 65
}
type stubMetricsHistoryProvider struct{}
+128 -4
View File
@@ -5228,6 +5228,128 @@ func TestContract_PerformanceReportTransportUsesCatalogDefinition(t *testing.T)
}
}
// Normalization keeps a disk's last-known temperature when the current
// observation did not collect one (a disk in standby, a host agent past its
// reporting lease). Reports present their disk tables as measured, so the
// performance report and the reporting runtime snapshot leave that value out.
func TestContract_ReportsOmitRetainedDiskTemperatures(t *testing.T) {
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
original := reporting.GetEngine()
reporting.SetEngine(engine)
t.Cleanup(func() { reporting.SetEngine(original) })
state := models.NewState()
state.Nodes = []models.Node{{ID: "node-1", Name: "node-a", Status: "online"}}
disk := func(id, devPath string, temperature int, collection diskinventory.FieldStatus) unifiedresources.Resource {
return unifiedresources.Resource{
ID: id, Type: unifiedresources.ResourceTypePhysicalDisk, Name: id, ParentName: "node-a",
Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"node-a"}},
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
DevPath: devPath, DiskType: "hdd", Health: "PASSED", Wearout: -1, Temperature: temperature,
Collection: &diskinventory.CollectionStatus{Temperature: collection},
},
}
}
monitor := newReportingMonitorForTest(t, state, []unifiedresources.Resource{
disk("disk-standby", "/dev/sda", 61, diskinventory.Unavailable("smartctl", "disk is in standby")),
disk("disk-silent", "/dev/sdb", 57, diskinventory.Unavailable("smartctl", "host agent stopped reporting")),
disk("disk-live", "/dev/sdc", 38, diskinventory.Available("smartctl")),
})
handler := NewReportingHandlers(newReportingMTMForTest(t, monitor), nil)
want := map[string]int{"/dev/sda": 0, "/dev/sdb": 0, "/dev/sdc": 38}
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=node&resourceId=node-1", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
rec := httptest.NewRecorder()
handler.HandleGenerateReport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
reported := map[string]int{}
for _, d := range engine.lastReq.Disks {
reported[d.Device] = d.Temperature
}
if !reflect.DeepEqual(reported, want) {
t.Fatalf("performance report disk temperatures = %v, want %v", reported, want)
}
snapshot, ok := handler.getRuntimeStateSnapshot(context.Background(), "default")
if !ok {
t.Fatal("expected runtime snapshot to be available")
}
snapshotted := map[string]int{}
for _, d := range snapshot.Disks {
snapshotted[d.Device] = d.Temperature
}
if !reflect.DeepEqual(snapshotted, want) {
t.Fatalf("reporting runtime snapshot disk temperatures = %v, want %v", snapshotted, want)
}
}
// Report disk tables colour a reading by the disk's alert disk temperature
// thresholds (the tenant's per-type policy), not a fixed 50/60C: amber from the
// clear value, red from the trigger, as the Physical Disks Temp column does.
func TestContract_ReportsCarryDiskTemperatureAlertThresholds(t *testing.T) {
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
original := reporting.GetEngine()
reporting.SetEngine(engine)
t.Cleanup(func() { reporting.SetEngine(original) })
state := models.NewState()
state.Nodes = []models.Node{{ID: "node-1", Name: "node-a", Status: "online"}}
disk := func(id, devPath, diskType string, temperature int) unifiedresources.Resource {
return unifiedresources.Resource{
ID: id, Type: unifiedresources.ResourceTypePhysicalDisk, Name: id, ParentName: "node-a",
Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"node-a"}},
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
DevPath: devPath, DiskType: diskType, Health: "PASSED", Wearout: -1, Temperature: temperature,
Collection: &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")},
},
}
}
monitor := newReportingMonitorForTest(t, state, []unifiedresources.Resource{
disk("disk-nvme", "/dev/nvme0n1", "nvme", 63),
disk("disk-sata", "/dev/sda", "sata", 56),
})
handler := NewReportingHandlers(newReportingMTMForTest(t, monitor), nil)
thresholdsByDevice := func() map[string][2]float64 {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=node&resourceId=node-1", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
rec := httptest.NewRecorder()
handler.HandleGenerateReport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
got := map[string][2]float64{}
for _, d := range engine.lastReq.Disks {
got[d.Device] = [2]float64{d.TemperatureWarning, d.TemperatureCritical}
}
return got
}
// No alert manager: the factory per-type policy.
if got, want := thresholdsByDevice(), map[string][2]float64{"/dev/nvme0n1": {65, 70}, "/dev/sda": {50, 55}}; !reflect.DeepEqual(got, want) {
t.Fatalf("factory report disk thresholds = %v, want %v", got, want)
}
manager := alerts.NewManager()
cfg := manager.GetConfig()
cfg.DiskTempByType["nvme"] = alerts.HysteresisThreshold{Trigger: 75, Clear: 70}
manager.UpdateConfig(cfg)
setUnexportedField(t, monitor, "alertManager", manager)
if got, want := thresholdsByDevice(), map[string][2]float64{"/dev/nvme0n1": {70, 75}, "/dev/sda": {50, 55}}; !reflect.DeepEqual(got, want) {
t.Fatalf("configured report disk thresholds = %v, want %v", got, want)
}
cfg = manager.GetConfig()
cfg.AgentDefaults.DiskTemperature = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
manager.UpdateConfig(cfg)
if got, want := thresholdsByDevice(), map[string][2]float64{"/dev/nvme0n1": {0, 0}, "/dev/sda": {0, 0}}; !reflect.DeepEqual(got, want) {
t.Fatalf("report disk thresholds with disk temperature alerting off = %v, want %v", got, want)
}
}
func TestContract_ReportingRequestCarriesEntitledReportBranding(t *testing.T) {
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
original := reporting.GetEngine()
@@ -16767,9 +16889,9 @@ func TestContract_PBSHostAgentComposesIntoOwningSystem(t *testing.T) {
// An agent on a Proxmox cluster node reports SMART data for the node's disks,
// and the registry stamps each disk with the owning node's identity so it
// stays discoverable in the Proxmox workspace. A disk is not a cluster member:
// projecting it as one folded its health into the node row, so a warm NVMe
// drive rendered an actively reporting node as Stale whenever the agent's
// report was newer than the last Proxmox poll.
// projecting it as one folded its health into the node row, so a disk with a
// SMART warning rendered an actively reporting node as Stale whenever the
// agent's report was newer than the last Proxmox poll.
func TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
monitor, err := monitoring.New(cfg)
@@ -16780,6 +16902,7 @@ func TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks(t *testing.T)
polledAt := time.Now().UTC().Add(-10 * time.Second)
reportedAt := polledAt.Add(6 * time.Second)
reallocatedSectors := int64(3)
adapter := unifiedresources.NewMonitorAdapter(nil)
adapter.PopulateFromSnapshot(models.StateSnapshot{
Nodes: []models.Node{{
@@ -16808,8 +16931,9 @@ func TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks(t *testing.T)
Model: "WD_BLACK SN7100 4TB",
Serial: "SN7100-AURORA",
Type: "nvme",
Temperature: 63,
Temperature: 41,
Health: "PASSED",
Attributes: &models.SMARTAttributes{ReallocatedSectors: &reallocatedSectors},
}},
},
}},
+45 -8
View File
@@ -14,12 +14,14 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
"github.com/rs/zerolog/log"
)
@@ -333,6 +335,9 @@ type reportingEnrichmentSnapshot struct {
RecentlyResolved []models.ResolvedAlert
LegacyBackups models.PVEBackups
Resources []unifiedresources.Resource
// alertManager supplies the tenant's disk temperature thresholds. Nil
// means the factory alert configuration.
alertManager *alerts.Manager
}
func emptyReportingEnrichmentSnapshot() reportingEnrichmentSnapshot {
@@ -404,6 +409,7 @@ func (h *ReportingHandlers) getReportingEnrichmentSnapshot(ctx context.Context,
RecentlyResolved: monitor.RecentlyResolvedSnapshot(),
LegacyBackups: monitor.PVEBackupsSnapshot(),
Resources: unifiedResources,
alertManager: monitor.GetAlertManager(),
}
snapshot.normalizeCollections()
return snapshot, true
@@ -875,20 +881,51 @@ func (h *ReportingHandlers) enrichNodeReport(req *reporting.MetricReportRequest,
continue
}
pd := r.PhysicalDisk
temperatureWarning, temperatureCritical := reportDiskTemperatureThresholds(snapshot.alertManager, pd.DiskType)
req.Disks = append(req.Disks, reporting.DiskInfo{
Device: pd.DevPath,
Model: pd.Model,
Serial: pd.Serial,
Type: pd.DiskType,
Size: pd.SizeBytes,
Health: pd.Health,
Temperature: pd.Temperature,
WearLevel: pd.Wearout,
Device: pd.DevPath,
Model: pd.Model,
Serial: pd.Serial,
Type: pd.DiskType,
Size: pd.SizeBytes,
Health: pd.Health,
Temperature: reportDiskTemperature(*pd),
WearLevel: pd.Wearout,
TemperatureWarning: temperatureWarning,
TemperatureCritical: temperatureCritical,
})
}
}
}
// reportDiskTemperatureThresholds returns the alert disk temperature thresholds
// a report colours a disk reading by: the clear value and the trigger, from
// the same policy as disk temperature alerts and the Physical Disks Temp
// column. Zero means disk temperature alerting is off for the disk.
func reportDiskTemperatureThresholds(manager *alerts.Manager, diskType string) (float64, float64) {
threshold := manager.DiskTemperatureThreshold(diskType)
if threshold == nil || threshold.Trigger <= 0 {
return 0, 0
}
warning := threshold.Clear
if warning <= 0 || warning > threshold.Trigger {
warning = threshold.Trigger
}
return warning, threshold.Trigger
}
// reportDiskTemperature returns the disk temperature a report may tabulate:
// one the current observation collected. Normalization may keep a last-known
// temperature it did not collect (a disk in standby, a host agent past its
// reporting lease); a report presents its values as measured, so that one is
// left out (0, rendered as no reading) rather than shown as current.
func reportDiskTemperature(pd unifiedresources.PhysicalDiskMeta) int {
if !diskinventory.TemperatureCollected(pd.Temperature, pd.Collection) {
return 0
}
return pd.Temperature
}
// enrichVMReport adds VM-specific data to the report request
func (h *ReportingHandlers) enrichVMReport(ctx context.Context, orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) {
// Find the VM
+1 -1
View File
@@ -191,7 +191,7 @@ func (h *ReportingHandlers) getRuntimeStateSnapshot(ctx context.Context, orgID s
Type: disk.DiskType,
Size: disk.SizeBytes,
Health: disk.Health,
Temperature: disk.Temperature,
Temperature: reportDiskTemperature(*disk),
WearLevel: disk.Wearout,
})
}
@@ -2234,7 +2234,8 @@ func TestProxmoxDiskAlertsRunOnMergedDiskState(t *testing.T) {
file: "monitor_pve.go",
snippets: []string{
"allDisks = mergeHostAgentSMARTIntoDisks(allDisks, nodesFromState, hosts)",
"m.alertManager.CheckDiskHealth(inst, disk.Node, proxmoxDiskFromPhysicalDisk(disk))",
"m.checkPhysicalDiskAlerts(inst, disk, diskExcludeByNode[disk.Node])",
"m.alertManager.CheckDiskHealth(instance, disk.Node, proxmoxDiskFromPhysicalDisk(disk))",
"func proxmoxDiskFromPhysicalDisk(disk models.PhysicalDisk) proxmox.Disk {",
},
},
@@ -319,8 +319,9 @@ func hasIssue1595Source(sources []unifiedresources.DataSource, want unifiedresou
}
// An agent report's SMART row may carry a temperature its collector did not
// collect this time (retained across a standby or failed probe). Only a
// collected temperature becomes a history sample.
// collect this time (retained across a standby or failed probe, or kept after
// the agent's reporting lease expired). Only a temperature
// diskinventory.TemperatureCollected accepts becomes a history sample.
func TestWriteHostSMARTMetricsRecordsOnlyCollectedTemperatures(t *testing.T) {
storeConfig := metrics.DefaultConfig(t.TempDir())
storeConfig.FlushInterval = time.Hour
@@ -337,6 +338,10 @@ func TestWriteHostSMARTMetricsRecordsOnlyCollectedTemperatures(t *testing.T) {
{Device: "sdb", Serial: "LEGACY1", Temperature: 38},
{Device: "sdc", Serial: "RETAINED1", Temperature: 36,
Collection: &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}},
{Device: "sdd", Serial: "SILENT1", Temperature: 44,
Collection: &diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("smartctl", models.HostAgentStoppedReportingReason)}},
{Device: "sde", Serial: "MISSING1", Temperature: 39,
Collection: &diskinventory.CollectionStatus{Temperature: diskinventory.Missing("smartctl", "temperature attribute absent")}},
}}}
now := time.Now()
m.writeHostSMARTMetrics(host, now)
@@ -349,6 +354,8 @@ func TestWriteHostSMARTMetricsRecordsOnlyCollectedTemperatures(t *testing.T) {
{host.Sensors.SMART[0], 1},
{host.Sensors.SMART[1], 1},
{host.Sensors.SMART[2], 0},
{host.Sensors.SMART[3], 0},
{host.Sensors.SMART[4], 0},
} {
id := unifiedresources.HostSMARTDiskSourceID(host, tc.disk)
points, err := store.Query("disk", id, "smart_temp", now.Add(-time.Minute), now.Add(time.Minute), 0)
+2 -18
View File
@@ -939,7 +939,7 @@ func (m *Monitor) writeSMARTMetrics(disk models.PhysicalDisk, now time.Time) {
return
}
if diskTemperatureCollected(disk.Temperature, disk.Collection) && m.metricsHistory != nil {
if diskinventory.TemperatureCollected(disk.Temperature, disk.Collection) && m.metricsHistory != nil {
m.metricsHistory.AddDiskMetric(resourceID, "smart_temp", float64(disk.Temperature), now)
}
@@ -953,22 +953,6 @@ func (m *Monitor) writeSMARTMetrics(disk models.PhysicalDisk, now time.Time) {
}
}
// diskTemperatureCollected reports whether a disk temperature was collected by
// its current observation. Normalization may carry a last-known temperature
// when it was not (standby, an agent past its reporting lease); that value must
// not be recorded as a new history sample. A temperature without collection
// state predates the contract and keeps the old behavior.
func diskTemperatureCollected(temperature int, collection *diskinventory.CollectionStatus) bool {
if temperature <= 0 {
return false
}
if collection == nil {
return true
}
state := collection.Temperature.State
return state == "" || state == diskinventory.FieldAvailable
}
// smartMetricStoreWrites builds the persisted SMART writes for one physical
// disk observation. The caller owns batching so the unified sync can anchor the
// observation time and drop exact replays (#1966).
@@ -989,7 +973,7 @@ func (m *Monitor) smartMetricStoreWrites(disk models.PhysicalDisk, resourceID st
})
}
if diskTemperatureCollected(disk.Temperature, disk.Collection) {
if diskinventory.TemperatureCollected(disk.Temperature, disk.Collection) {
appendWrite("smart_temp", float64(disk.Temperature))
}
+2 -1
View File
@@ -26,6 +26,7 @@ import (
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
"github.com/rcourtman/pulse-go-rewrite/pkg/agents/filesystem"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
"github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rs/zerolog"
@@ -4086,7 +4087,7 @@ func (m *Monitor) writeHostSMARTMetrics(host models.Host, now time.Time) {
continue
}
if diskTemperatureCollected(disk.Temperature, disk.Collection) {
if diskinventory.TemperatureCollected(disk.Temperature, disk.Collection) {
m.metricsStore.Write("disk", resourceID, "smart_temp", float64(disk.Temperature), now)
}
+43
View File
@@ -716,6 +716,14 @@ func (m *Monitor) checkMockAlerts() {
m.alertManager.CheckStorageWithCapacityTrend(storage, m.storageCapacityTrend(storage, time.Now()))
}
// Check alerts for physical disks. Live disks are evaluated by the
// physical disk poller, which only polls configured Proxmox instances, and
// the fixture deliberately keeps a FAILED cohort and worn SSDs, so without
// this pass the estate shows failing disks with no disk-health or
// disk-wearout alert.
log.Debug().Int("diskCount", len(state.PhysicalDisks)).Msg("checking physical disk alerts")
m.checkMockPhysicalDiskAlerts(state.PhysicalDisks, state.Nodes, state.Hosts)
// Check alerts for PBS instances
log.Debug().Int("pbsCount", len(state.PBSInstances)).Msg("checking PBS alerts")
for _, pbsInst := range state.PBSInstances {
@@ -815,3 +823,38 @@ func (m *Monitor) checkMockDockerHostAlerts(host models.DockerHost) {
}
m.alertManager.CheckDockerHost(host)
}
// checkMockPhysicalDiskAlerts applies the physical disk poller's alert
// boundary to fixture disks. The poller only evaluates disks on nodes it
// reached, so a disk on a node that is not online keeps whatever alert it
// had, and a device matched by the linked agent's --disk-exclude patterns is
// evaluated as healthy. The poller's wait for host-agent links to settle after
// a restart does not apply: fixture links and exclusions are complete from the
// first pass.
func (m *Monitor) checkMockPhysicalDiskAlerts(disks []models.PhysicalDisk, nodes []models.Node, hosts []models.Host) {
type nodeKey struct{ instance, name string }
excludeByHost := make(map[string][]string, len(hosts))
for _, host := range hosts {
if len(host.DiskExclude) > 0 {
excludeByHost[host.ID] = host.DiskExclude
}
}
onlineNodes := make(map[nodeKey]bool, len(nodes))
excludeByNode := make(map[nodeKey][]string)
for _, node := range nodes {
key := nodeKey{instance: node.Instance, name: node.Name}
onlineNodes[key] = node.Status == "online"
if patterns := excludeByHost[node.LinkedAgentID]; node.LinkedAgentID != "" && len(patterns) > 0 {
excludeByNode[key] = patterns
}
}
for _, disk := range disks {
key := nodeKey{instance: disk.Instance, name: disk.Node}
if !onlineNodes[key] {
continue
}
m.checkPhysicalDiskAlerts(disk.Instance, disk, excludeByNode[key])
}
}
+29 -15
View File
@@ -8,6 +8,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
@@ -267,11 +268,13 @@ func (m *Monitor) GetPhysicalDiskTemperatureCharts(duration time.Duration) map[s
// Phase 1: Collect disk metadata and resource IDs.
type diskMeta struct {
resourceID string
name string
node string
instance string
temperature int
resourceID string
name string
node string
instance string
// collectedTemperature is the reading the current observation took,
// or 0 when the disk only retains a last-known value.
collectedTemperature int
}
var disks []diskMeta
for _, disk := range readState.PhysicalDisks() {
@@ -294,12 +297,16 @@ func (m *Monitor) GetPhysicalDiskTemperatureCharts(duration time.Duration) map[s
if name == "" {
name = strings.TrimSpace(disk.DevPath())
}
collectedTemperature := 0
if diskinventory.TemperatureCollected(disk.Temperature(), disk.Collection()) {
collectedTemperature = disk.Temperature()
}
disks = append(disks, diskMeta{
resourceID: resourceID,
name: name,
node: strings.TrimSpace(disk.Node()),
instance: strings.TrimSpace(disk.Instance()),
temperature: disk.Temperature(),
resourceID: resourceID,
name: name,
node: strings.TrimSpace(disk.Node()),
instance: strings.TrimSpace(disk.Instance()),
collectedTemperature: collectedTemperature,
})
}
@@ -349,12 +356,19 @@ func (m *Monitor) GetPhysicalDiskTemperatureCharts(duration time.Duration) map[s
// Sparklines require >= 2 points. If the store returned 0 or 1 points
// but the disk has a live temperature reading, pad to 2 points so the
// chart can render (flat line at current temperature).
// chart can render (flat line at current temperature). A retained
// last-known temperature (standby, a silent host agent) is not a
// reading taken now, so the stored samples stay as they are and a
// disk with none has no series.
if len(tempPoints) < 2 {
now := time.Now()
tempPoints = []MetricPoint{
{Timestamp: now.Add(-60 * time.Second), Value: float64(d.temperature)},
{Timestamp: now, Value: float64(d.temperature)},
if d.collectedTemperature > 0 {
now := time.Now()
tempPoints = []MetricPoint{
{Timestamp: now.Add(-60 * time.Second), Value: float64(d.collectedTemperature)},
{Timestamp: now, Value: float64(d.collectedTemperature)},
}
} else if len(tempPoints) == 0 {
continue
}
}

Some files were not shown because too many files have changed in this diff Show More