mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Judge disk heat under the reporting host's Disk Temp override (#2654)
#2636 made the alert disk temperature policy the only judge of disk heat, but it resolved that policy by disk type alone. Disk temperature alerts (`CheckHost`) evaluate each SMART disk under the thresholds of the host agent that reports it. An explicit Disk Temp override on that host, or one inherited from its linked node or guest, replaces the per-type trigger, and a host whose alerts are switched off raises nothing. So a host with an 80C Disk Temp override still read **Running Hot** on Physical Disks at the 70C NVMe trigger. Patrol flagged it too, and a host with alerts switched off still read hot. The Alerts thresholds page promises the opposite: "Setting a Disk Temp override on a host above replaces these for all of that host's disks". ## What changes - **Backend policy:** `alerts.Manager.HostDiskTemperatureThreshold(host, diskType)` resolves the policy for a host the way `CheckHost` does, from the same helpers (`resolveHostThresholdsNoLock`, `hostThresholdOverrideNoLock`, `hostDiskTemperatureThresholdNoLock`). A disabled override gives no threshold. `DiskTemperatureThreshold(diskType)` is now its hostless case. - **Patrol and the AI chat context:** `ThresholdProvider.GetDiskTemperatureThreshold(host, diskType)`. Each unified physical disk finds its host by walking its parent to the agent machine, through the pool for Unraid array and cache disks. - **Physical Disks, the disk detail, the pool drawer's linked disks and the guest drawer** pass the reporting machine's override keys to `resolveDiskTemperatureDisplayThresholds`. Keys follow `CheckHost`'s order: agent ID, canonical resource ID, then the merged node or guest. The resolver returns null (uncoloured, never hot) for an override or agent default that switches the host's alerts off. The browser rebuilds `CheckHost`'s key chain from what it loads, so a few identity edge cases can still differ from alerts and Patrol (listed in `storage-recovery.md`): on the Storage page, a disk whose agent an operator manually linked into a VM; an override inherited through agent link hints the API does not publish (an unmerged linked node or guest), or stored under a linked node's own canonical ID; and guest keys in the guest drawer for an agent merged into a guest it is not automatically linked to. Overrides set on the agent's own row, or on the node or guest it is merged with, reach every surface. ## Verification - `TestHostDiskTemperatureThresholdMatchesCheckHostOverrides` checks each case against what `CheckHost` actually fires: no override, host override, host override without Disk Temp, an override under the canonical resource the agent ID resolves to, linked-node and linked-guest overrides, a host override shadowing a node one, a switched-off Disk Temp, and a disabled host, linked node or linked guest. - `TestPatrolJudgesAgentDisksByTheAgentDiskTemperatureOverride` uses a real registry with two agents, one overridden. A mutation that drops the owner lookup fails it. - Frontend tests cover the resolver, the owner walk and key order, DiskList with three hosts, the presentation map, pool-drawer linked disks and the guest drawer. - Private mock stack: three NVMe disks pinned at 72C. Before (main frontend): all three read Running Hot. After: - pve1, with an 80C override, reads Healthy (green). - pve2 still reads Running Hot. - pve3, with alerts off, is uncoloured. - Moving pve2's override onto its linked node ID turns it Healthy. - Checked at 1440 and 375 with no overflow. The receipt is in `frontend-modern/browser-verification/`. - A read-only second-opinion review ran twice. Round 1's findings (frontend key order, guest drawer canonical ID, disabled agent default, doc wording) are fixed in the first commit. Round 2's are the parity cases and the documented browser limits in the second commit.
This commit is contained in:
@@ -329,10 +329,17 @@ retained value as "last known 41C (disk is in standby)". Proofs:
|
||||
`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
|
||||
not a fixed line. `ThresholdProvider.GetDiskTemperatureThreshold(host, diskType)`
|
||||
(the alert manager's `HostDiskTemperatureThreshold` trigger and clear) resolves
|
||||
each disk's limits once in `patrolPhysicalDiskRows` and in the AI chat context,
|
||||
for the host agent that reports the disk. `physicalDiskTemperatureHost` finds it
|
||||
by walking the disk's parent to the agent machine, through the pool for an
|
||||
Unraid disk, so that agent's Disk Temp override (or one inherited from its
|
||||
linked node or guest) replaces the per-type trigger, and an override or agent
|
||||
default that switches the host's alerts off leaves no heat to judge. A disk no
|
||||
agent reports gets the per-type policy. The global alerts switch and the agent
|
||||
alert-type switch stop `CheckHost` without changing this policy, as before.
|
||||
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
|
||||
@@ -341,8 +348,10 @@ 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/patrol_disk_temperature_test.go` (a real registry with one
|
||||
overridden agent in `TestPatrolJudgesAgentDisksByTheAgentDiskTemperatureOverride`),
|
||||
`TestAlertThresholdAdapter_DiskTemperatureFollowsAlertPolicy` and
|
||||
`TestAlertThresholdAdapter_DiskTemperatureHonoursHostOverrides` in
|
||||
`internal/ai/alert_threshold_adapter_test.go`.
|
||||
|
||||
Retained summaries disclose that point and bucket timestamps describe returned
|
||||
|
||||
@@ -1789,14 +1789,26 @@ an alert the next evaluation raises again.
|
||||
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`.
|
||||
hot". `diskTemperatureThresholdForType` in `internal/alerts/host.go` holds it.
|
||||
`Manager.HostDiskTemperatureThreshold(host, diskType)` resolves it for the host
|
||||
agent that reports a disk exactly as `CheckHost` does: an explicit Disk Temp
|
||||
override on that host, or one inherited from its linked node or guest, replaces
|
||||
the per-type entry, the first override in that chain decides even when it sets
|
||||
no Disk Temp, and an override that switches the host's alerts off leaves no
|
||||
threshold. `TestHostDiskTemperatureThresholdMatchesCheckHostOverrides` pins each
|
||||
case against what `CheckHost` fires, including an override under the canonical
|
||||
resource the agent ID resolves to and one inherited from a linked guest. `Manager.DiskTemperatureThreshold(diskType)`
|
||||
is the hostless case. Patrol reads the policy 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.
|
||||
and Health verdict mirror the policy in `resolveDiskTemperatureDisplayThresholds`
|
||||
under the override keys of the machine whose agent reports the disk, in the
|
||||
order `CheckHost` reads them as far as the browser can see that chain
|
||||
(`storage-recovery.md` lists where it cannot). That resolver returns null when the agent Disk
|
||||
Temp default is off or an override or agent default switches the host's alerts
|
||||
off. The global alerts switch and the agent alert-type switch stop `CheckHost`
|
||||
without changing the policy.
|
||||
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
|
||||
|
||||
@@ -429,7 +429,9 @@ 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`).
|
||||
that colours its Temp cell (`getPhysicalDiskTemperatureThresholds`). Callers
|
||||
pass the override keys of the machine that reports the disk, so that machine's
|
||||
Disk Temp override sets those thresholds.
|
||||
|
||||
Controller expansions carry the supplied kind-specific targets, absolute times,
|
||||
duration and cluster/namespace when the phone row omits those columns. Proxmox
|
||||
|
||||
@@ -3448,6 +3448,10 @@ whole-estate physical-disk work to Workloads. The guest's existing agent RAID
|
||||
snapshot remains local to the opened drawer; an empty or failed child query
|
||||
must not imply that host disks belong to the guest. Focused hook and drawer
|
||||
tests pin the query and no-data/error presentations.
|
||||
The drawer judges those disks' heat under the guest agent's alert overrides:
|
||||
its `agentId`, which `useWorkloads` copies from `resource.agent.agentId` onto
|
||||
the workload row, then the guest's own override keys. That reads the alert
|
||||
configuration already loaded and adds no query, poll or per-row work.
|
||||
|
||||
### Configuration transfer authorization stays off persistence hot paths
|
||||
|
||||
|
||||
@@ -6789,13 +6789,36 @@ 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
|
||||
verdict read one thresholds object. It resolves them under the alert override
|
||||
keys of the machine whose agent reports the disk, as disk temperature alerts
|
||||
are evaluated under that host. `getPhysicalDiskAlertResourceIds`
|
||||
(`components/Storage/physicalDiskAlertOwner.ts`) follows the disk's parent to
|
||||
the agent machine, through the pool for an Unraid array or cache disk, and
|
||||
reads that machine's keys in the order `CheckHost` reads them
|
||||
(`getAgentMachineAlertOverrideKeys`: the agent ID, the machine's canonical ID,
|
||||
then the node or guest the agent is merged with).
|
||||
A Disk Temp override set on the machine therefore replaces the per-type trigger
|
||||
for every disk it reports, on Physical Disks, the disk detail and the pool
|
||||
drawer's linked disks. A guest drawer passes the guest agent's ID, then the
|
||||
guest's own keys. An override that switches the machine's alerts off leaves its
|
||||
disks uncoloured and never hot. 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
|
||||
The browser rebuilds that chain from what it loads, so it can differ from
|
||||
alerts and Patrol, which read the agent's own link hints and resolve every key
|
||||
to its canonical resource. On the Storage page, a disk whose agent an operator
|
||||
manually linked into a VM keeps the per-type trigger, because the page loads
|
||||
agents and pools, not guests. An override the agent inherits from a node or
|
||||
guest it is linked to without a merge, or one stored under that node's own
|
||||
canonical resource ID, does not reach the browser, because agent link hints
|
||||
are not published. The guest drawer applies the guest's own override keys,
|
||||
which `CheckHost` reads only when the agent is also linked to that guest
|
||||
automatically. Overrides set on the reporting agent's own row, or on the node
|
||||
or guest it is merged with, reach the browser through the keys above. 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`
|
||||
@@ -6803,7 +6826,10 @@ 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.
|
||||
store, and NVMe disks at 72C on three machines judged by an 80C override, the
|
||||
per-type trigger and switched-off alerts.
|
||||
`frontend-modern/src/components/Storage/__tests__/physicalDiskAlertOwner.test.ts`
|
||||
pins the owner walk and the key order.
|
||||
|
||||
### Pool-to-physical-disk ownership in Storage details
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "38ce73c24a7fdf9f3523fbf7f9f9a23ac2be7276",
|
||||
"verified_at": "2026-10-07T10:53:35Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Storage/DiskDetail.tsx",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx",
|
||||
"frontend-modern/src/components/Storage/Storage.tsx",
|
||||
"frontend-modern/src/components/Storage/StorageContentCard.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolDetail.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolRow.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx",
|
||||
"frontend-modern/src/components/Storage/physicalDiskAlertOwner.ts",
|
||||
"frontend-modern/src/components/Storage/useDiskDetailModel.ts",
|
||||
"frontend-modern/src/components/Storage/useDiskListModel.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageResources.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx",
|
||||
"frontend-modern/src/features/storageBackups/diskPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts",
|
||||
"frontend-modern/src/types/workloads.ts",
|
||||
"frontend-modern/src/utils/metricThresholds.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Storage/DiskDetail.tsx": "f6617192db42acb3da59297ef86e03e2d01dafc16cb5744ad87f84ffd37f6773",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx": "0a457320ac9aea99618f9c1be12cd85a59d0536341436b225eeaeadec09896f3",
|
||||
"frontend-modern/src/components/Storage/Storage.tsx": "c1ecf5d710c427f0acc8230728e15648376628b43131f8504f12e0ff841dccff",
|
||||
"frontend-modern/src/components/Storage/StorageContentCard.tsx": "faa6896330d7ea74b6886fc3d449fe8bab3b6275f00bf15e7c9d8bb24a1b2d8e",
|
||||
"frontend-modern/src/components/Storage/StoragePoolDetail.tsx": "0a6401690187f4b3fa886ece2862edb34cf99122e175a1c4806384cdc8e4fed4",
|
||||
"frontend-modern/src/components/Storage/StoragePoolRow.tsx": "ce177b691b800852f35ec66267150e6259d3bf9cc65b3492bbe7055879dade7c",
|
||||
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "ad1bc1967c8e4561a943ec12629f3c64ccb0423ebe6551d4a8ccd2c6afe72872",
|
||||
"frontend-modern/src/components/Storage/physicalDiskAlertOwner.ts": "279aa8cd48705a31e91579bdbcf4964495301bb7478a4fa0c4549959596dd9fa",
|
||||
"frontend-modern/src/components/Storage/useDiskDetailModel.ts": "b47926cc53cca725a2bca4300742b5ad211103140b809c86140648affa6d5b36",
|
||||
"frontend-modern/src/components/Storage/useDiskListModel.ts": "84aa1e3c298f614a0e2e7da012ea0cc4f7f8993d9fe686d67c7865cd84298de5",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts": "d274908f0a7fa531921f226936bab93166676904dc8544717f7fa2172ddb37d3",
|
||||
"frontend-modern/src/components/Storage/useStoragePageResources.ts": "adf35ccbe0fcae8d7559177419be0e9086720b6e039632b9c1ea829f7f7bd6c1",
|
||||
"frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts": "7654fbb97eaf9d1ed20b7ef41c73a0061f83f29cb989ded5e3e9b4b65c0e23ef",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "9a35f11b805b2c067afd9aeff08a2cc017caa345a72f1512a8d1bf0fc98b5615",
|
||||
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "74f6b660e4d91bffad9c1c8e7580a7085036bff68e15daaf7709b4c3f615d5e3",
|
||||
"frontend-modern/src/features/storageBackups/diskPresentation.ts": "ce492c2348a7e2a690b2613d0e8c0284b6de37dd5b0c0e63cd32c22bdfe150db",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "75765a1f902eb717aacc995143d750f0fa5957e6217442995566d00592f9effa",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "f25d948645c4de8984ff53242f0426c42ee24f2e5f916b4906c80faf50772be1",
|
||||
"frontend-modern/src/types/workloads.ts": "013a5de46caf8c2d968fd1459383f0fcf0fb09efbc924b46c9aa781683d6c5e9",
|
||||
"frontend-modern/src/utils/metricThresholds.ts": "cbe2299b0a2e3b917d7fa8c0c8ccabb2f7a0862708736636c74908f969e5c9ea"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/storage?tab=disks&q=West%20Production"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (8 nodes, each with a linked Pulse agent): Vite dev server on this exact staged frontend tree, backend built from the same tree with a local-only generator overlay (never committed) pinning West Production A/B/C NVMe disks at 72C and a pve3 SATA disk at 41C. Each disk's parent is its node's merged agent machine (agent IDs host-node-mock-cluster-1-pve1/2/3)",
|
||||
"Overrides set through GET/PUT /api/alerts/config: host-node-mock-cluster-1-pve1 diskTemperature 80/75, host-node-mock-cluster-1-pve3 disabled; pve2 none. diskTempByType nvme 70/65",
|
||||
"Same-session control with the changed runtime files swapped to origin/main under the running Vite (then restored, full diff cmp-identical): all three NVMe disks read 'Running Hot' with red 72C cells, including pve1 under its 80C override (detail: 'Disk temperature is 72\u00b0C, at or above its 70\u00b0C alert threshold.') and pve3 with alerts off",
|
||||
"After, 1440: pve1 NVMe 72C 'Healthy' with a green cell (rgb(22,163,74)); pve2 NVMe 72C 'Running Hot' with a red cell (rgb(220,38,38)); pve3 NVMe 72C uncoloured (text-base-content) and not hot (its mock SMART health is UNKNOWN, so the verdict reads 'Unknown'), and pve3's other disks lose their threshold colour too; no horizontal overflow (scrollWidth 1440)",
|
||||
"After, 375: same verdicts in compact form (pve1 'Healthy' green, pve2 'Hot' red, pve3 uncoloured); scrollWidth 375",
|
||||
"After, inline disk detail for pve1's NVMe: health 'Healthy' and Temperature 72\u00b0C, agreeing with its row",
|
||||
"After, override moved to pve2's linked Proxmox node ID (mock-cluster-1-pve2, diskTemperature 80/75): pve2's NVMe reads 'Healthy' with a green cell, as CheckHost inherits a linked-node override; config restored afterwards",
|
||||
"Not reachable on the mock estate and covered by unit tests instead: pool-drawer linked disks (storagePoolDetailPresentation/useStoragePoolDetailModel tests), the guest drawer for an agent merged into a VM (GuestPhysicalDisks.test.tsx), and the owner walk through an Unraid pool (physicalDiskAlertOwner.test.ts)"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded Physical Disks filtered to West Production at 1440x900 and 375x812 and read each pinned row's verdict, Temp cell class and computed colour, before (main frontend) and after",
|
||||
"Clicked pve1's NVMe row to open its inline disk detail and read its health and temperature",
|
||||
"Changed alert overrides through GET/PUT /api/alerts/config (host-keyed, then node-keyed) and reloaded Physical Disks after each",
|
||||
"Measured document scrollWidth at both widths"
|
||||
]
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import { useDiskDetailModel } from './useDiskDetailModel';
|
||||
interface DiskDetailProps {
|
||||
disk: Resource;
|
||||
nodes: Resource[];
|
||||
/** Alert override keys of the machine whose agent reports the disk. */
|
||||
alertResourceIds?: string[];
|
||||
}
|
||||
|
||||
type DiskDetailTab = 'overview' | 'history';
|
||||
@@ -65,6 +67,7 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
||||
liveIOAvailable,
|
||||
} = useDiskDetailModel({
|
||||
disk: () => props.disk,
|
||||
alertResourceIds: () => props.alertResourceIds,
|
||||
});
|
||||
const rangeOptions = createMemo(() =>
|
||||
getUnlockedHistoryRangeOptions(DISK_DETAIL_HISTORY_RANGE_OPTIONS, maxHistoryDays()),
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
getPhysicalDiskTableLayoutModeForContainer,
|
||||
getPhysicalDiskTemperaturePresentation,
|
||||
isPhysicalDiskColumnVisible,
|
||||
type PhysicalDiskAlertResourceIdResolver,
|
||||
type PhysicalDiskTableColumnId,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
|
||||
@@ -98,6 +99,8 @@ import { useDiskListModel } from './useDiskListModel';
|
||||
|
||||
interface DiskListProps {
|
||||
disks: Resource[];
|
||||
/** Alert override keys of the machine reporting each disk; absent means none. */
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
nodes: Resource[];
|
||||
selectedNode: string | null;
|
||||
sourceFilter?: string;
|
||||
@@ -134,6 +137,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
getPhysicalDiskColumnWidthStyle(layoutMode(), columnId);
|
||||
const model = useDiskListModel({
|
||||
disks: () => props.disks,
|
||||
getDiskAlertResourceIds: (disk) => props.getDiskAlertResourceIds?.(disk) ?? [],
|
||||
nodes: () => props.nodes,
|
||||
selectedNode: () => props.selectedNode,
|
||||
sourceFilter: () => props.sourceFilter ?? 'all',
|
||||
@@ -569,7 +573,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
colSpan={9}
|
||||
class={PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS}
|
||||
>
|
||||
<DiskDetail disk={disk} nodes={props.nodes} />
|
||||
<DiskDetail
|
||||
disk={disk}
|
||||
nodes={props.nodes}
|
||||
alertResourceIds={props.getDiskAlertResourceIds?.(disk)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Show>
|
||||
|
||||
@@ -57,6 +57,7 @@ const Storage: Component<StorageProps> = (props) => {
|
||||
focusedSummaryStorageGroupId,
|
||||
hoveredSummaryStorageGroupScope,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
nodes,
|
||||
groupedRecords,
|
||||
expandedGroups,
|
||||
@@ -139,6 +140,7 @@ const Storage: Component<StorageProps> = (props) => {
|
||||
<StorageContentCard
|
||||
view={view}
|
||||
physicalDisks={physicalDisks}
|
||||
getDiskAlertResourceIds={getDiskAlertResourceIds}
|
||||
nodes={nodes}
|
||||
sourceFilter={sourceFilter}
|
||||
healthFilter={healthFilter}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { STORAGE_CONTENT_CARD_BODY_CLASS } from '@/features/storageBackups/stora
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { StorageHealthFilter } from '@/features/storageBackups/models';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { StorageGroupKey, StorageGroupedRecords } from './useStorageModel';
|
||||
import type { StorageAlertRowState } from '@/features/storageBackups/storageAlertState';
|
||||
import type { StorageView } from './storagePageState';
|
||||
@@ -16,6 +17,7 @@ import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInt
|
||||
type StorageContentCardProps = {
|
||||
view: () => StorageView;
|
||||
physicalDisks: () => Resource[];
|
||||
getDiskAlertResourceIds: PhysicalDiskAlertResourceIdResolver;
|
||||
nodes: () => Resource[];
|
||||
sourceFilter: () => string;
|
||||
healthFilter: () => StorageHealthFilter;
|
||||
@@ -75,6 +77,7 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
|
||||
<div class={STORAGE_CONTENT_CARD_BODY_CLASS}>
|
||||
<DiskList
|
||||
disks={props.physicalDisks()}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
nodes={props.nodes()}
|
||||
sourceFilter={props.sourceFilter()}
|
||||
healthFilter={props.healthFilter()}
|
||||
@@ -100,6 +103,7 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
|
||||
storageGrowthBySeriesId={props.storageGrowthBySeriesId()}
|
||||
storageGrowthColumnLabel={props.storageGrowthColumnLabel()}
|
||||
physicalDisks={props.physicalDisks()}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
nodeOnlineByLabel={props.nodeOnlineByLabel()}
|
||||
highlightedRecordId={props.highlightedRecordId()}
|
||||
getRecordAlertState={props.getRecordAlertState}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS,
|
||||
} from '@/features/storageBackups/detailPresentation';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { formatTemperature } from '@/utils/temperature';
|
||||
import { StorageDetailKeyValueRow } from './StorageDetailKeyValueRow';
|
||||
@@ -47,6 +48,7 @@ import { useStoragePoolDetailModel } from './useStoragePoolDetailModel';
|
||||
interface StoragePoolDetailProps {
|
||||
record: StorageRecord;
|
||||
physicalDisks: Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
summarySeriesId: string;
|
||||
controlsId?: string;
|
||||
}
|
||||
@@ -67,6 +69,7 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
|
||||
} = useStoragePoolDetailModel({
|
||||
record: () => props.record,
|
||||
physicalDisks: () => props.physicalDisks,
|
||||
getDiskAlertResourceIds: props.getDiskAlertResourceIds,
|
||||
});
|
||||
const rangeOptions = createMemo(() =>
|
||||
getUnlockedHistoryRangeOptions(STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS, maxHistoryDays()),
|
||||
@@ -285,7 +288,10 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
|
||||
? PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS
|
||||
: getLinkedDiskTemperatureTextClass(
|
||||
disk.temperature,
|
||||
getDiskTemperatureThresholds(disk.diskType),
|
||||
getDiskTemperatureThresholds(
|
||||
disk.diskType,
|
||||
disk.alertResourceIds,
|
||||
),
|
||||
)
|
||||
}`}
|
||||
title={disk.temperatureLastKnownTitle}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
STORAGE_POOL_ROW_USAGE_WRAP_CLASS,
|
||||
} from '@/features/storageBackups/storagePoolRowPresentation';
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getStoragePoolCellPaddingClass,
|
||||
@@ -57,6 +58,7 @@ interface StoragePoolRowProps {
|
||||
alertHeadlineCompact?: string | null;
|
||||
alertHeadlineClass?: string;
|
||||
physicalDisks: Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
alertDataAttrs: {
|
||||
'data-row-id': string;
|
||||
'data-alert-state': string;
|
||||
@@ -247,6 +249,7 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
|
||||
<StoragePoolDetail
|
||||
record={props.record}
|
||||
physicalDisks={props.physicalDisks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
summarySeriesId={props.summarySeriesId}
|
||||
controlsId={detailControlsId()}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
|
||||
import { resolveStorageRecordMetricResourceId } from '@/features/storageBackups/storageMetricsIdentity';
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { StorageAlertRowState } from '@/features/storageBackups/storageAlertState';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { StorageGroupRow } from './StorageGroupRow';
|
||||
import { StoragePoolRow } from './StoragePoolRow';
|
||||
@@ -45,6 +46,7 @@ type StoragePoolsTableProps = {
|
||||
storageGrowthBySeriesId: Map<string, StorageCapacityDeltaPresentation>;
|
||||
storageGrowthColumnLabel: string;
|
||||
physicalDisks: Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
nodeOnlineByLabel: Map<string, boolean>;
|
||||
highlightedRecordId: string | null;
|
||||
getRecordAlertState: (recordId: string) => StorageAlertRowState;
|
||||
@@ -195,6 +197,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
|
||||
alertHeadlineCompact={rowModel().alertHeadlineCompact}
|
||||
alertHeadlineClass={rowModel().alertHeadlineClass}
|
||||
physicalDisks={props.physicalDisks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
alertDataAttrs={rowModel().alertDataAttrs}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -69,12 +69,14 @@ describe('DiskList', () => {
|
||||
nodes: Resource[];
|
||||
selectedNode: string | null;
|
||||
searchTerm: string;
|
||||
getDiskAlertResourceIds?: (disk: Resource) => string[];
|
||||
}) =>
|
||||
render(() => {
|
||||
const [selectedDiskId, setSelectedDiskId] = createSignal<string | null>(null);
|
||||
return (
|
||||
<DiskList
|
||||
disks={props.disks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
nodes={props.nodes}
|
||||
selectedNode={props.selectedNode}
|
||||
searchTerm={props.searchTerm}
|
||||
@@ -475,6 +477,48 @@ describe('DiskList', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('judges each disk by the Disk Temp override of the machine that reports it', async () => {
|
||||
const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
activationState: 'active',
|
||||
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
|
||||
diskTempByType: { nvme: { trigger: 70, clear: 65 } },
|
||||
overrides: {
|
||||
'host-pve3': { diskTemperature: { trigger: 80, clear: 75 } },
|
||||
'host-pve5': { disabled: true },
|
||||
},
|
||||
} as unknown as AlertConfig);
|
||||
try {
|
||||
const view = renderDiskList({
|
||||
disks: [
|
||||
buildDisk('nvme-pve3', 'pve3', { diskType: 'nvme', temperature: 72 }),
|
||||
buildDisk('nvme-pve4', 'pve4', { diskType: 'nvme', temperature: 72 }),
|
||||
buildDisk('nvme-pve5', 'pve5', { diskType: 'nvme', temperature: 72 }),
|
||||
],
|
||||
nodes: [],
|
||||
selectedNode: null,
|
||||
searchTerm: '',
|
||||
getDiskAlertResourceIds: (disk) => [`host-${disk.parentId?.replace('node-', '')}`],
|
||||
});
|
||||
const row = (id: string) =>
|
||||
within(view.container.querySelector(`[data-row-id="${id}"]`) as HTMLElement);
|
||||
|
||||
await useAlertsActivation().refreshConfig();
|
||||
// pve3's 80C override puts 72C under its warning band: healthy and green.
|
||||
await waitFor(() => expect(row('nvme-pve3').getByText('Healthy')).toBeInTheDocument());
|
||||
expect(row('nvme-pve3').getByText('72°C')).toHaveClass('text-green-600');
|
||||
// pve4 has no override, so the 70C NVMe trigger judges its disk.
|
||||
expect(row('nvme-pve4').getByText('Running Hot')).toBeInTheDocument();
|
||||
expect(row('nvme-pve4').getByText('72°C')).toHaveClass('text-red-600');
|
||||
// pve5's alerts are switched off, so its disk is never judged hot.
|
||||
expect(row('nvme-pve5').queryByText('Running Hot')).not.toBeInTheDocument();
|
||||
expect(row('nvme-pve5').getByText('Healthy')).toBeInTheDocument();
|
||||
} finally {
|
||||
getConfig.mockRestore();
|
||||
eventBus.emit('org_switched', 'default');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
|
||||
renderDiskList({
|
||||
disks: [
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getAgentMachineAlertOverrideKeys,
|
||||
getPhysicalDiskAlertResourceIds,
|
||||
indexPhysicalDiskOwners,
|
||||
} from '@/components/Storage/physicalDiskAlertOwner';
|
||||
|
||||
const resource = (fields: Record<string, unknown>): Resource =>
|
||||
({ name: String(fields.id), status: 'online', lastSeen: 0, ...fields }) as unknown as Resource;
|
||||
|
||||
const agentMachine = resource({
|
||||
id: 'agent-tower',
|
||||
type: 'agent',
|
||||
agent: { agentId: 'host-tower' },
|
||||
proxmox: { sourceId: 'lab-tower', nodeName: 'tower' },
|
||||
});
|
||||
const nodeWithoutAgent = resource({
|
||||
id: 'node-bare',
|
||||
type: 'agent',
|
||||
proxmox: { sourceId: 'lab-bare', nodeName: 'bare' },
|
||||
});
|
||||
const unraidArray = resource({ id: 'storage-array', type: 'storage', parentId: 'agent-tower' });
|
||||
const disk = (parentId?: string) =>
|
||||
resource({ id: `disk-${parentId}`, type: 'physical_disk', parentId });
|
||||
|
||||
describe('physical disk alert owner', () => {
|
||||
const owners = indexPhysicalDiskOwners([
|
||||
agentMachine,
|
||||
nodeWithoutAgent,
|
||||
unraidArray,
|
||||
disk('agent-tower'),
|
||||
]);
|
||||
|
||||
it('indexes only agent machines and storage pools', () => {
|
||||
expect([...owners.keys()].sort()).toEqual(['agent-tower', 'storage-array']);
|
||||
});
|
||||
|
||||
it('reads the reporting machine override keys in the order CheckHost reads them', () => {
|
||||
// Agent ID, the canonical ID it resolves to, then the merged node.
|
||||
const expected = ['host-tower', 'agent-tower', 'lab-tower'];
|
||||
expect(getAgentMachineAlertOverrideKeys(agentMachine)).toEqual(expected);
|
||||
expect(getPhysicalDiskAlertResourceIds(disk('agent-tower'), owners)).toEqual(expected);
|
||||
// An Unraid array or cache disk hangs off its pool, which hangs off the machine.
|
||||
expect(getPhysicalDiskAlertResourceIds(disk('storage-array'), owners)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('follows an agent merged into a guest to the guest override keys', () => {
|
||||
const guest = resource({
|
||||
id: 'vm-unraid',
|
||||
type: 'vm',
|
||||
agent: { agentId: 'host-unraid' },
|
||||
proxmox: { instance: 'lab', node: 'tower', nodeName: 'tower', vmid: 105 },
|
||||
});
|
||||
const keys = getAgentMachineAlertOverrideKeys(guest);
|
||||
expect(keys.slice(0, 2)).toEqual(['host-unraid', 'vm-unraid']);
|
||||
expect(keys).toContain('lab:tower:105');
|
||||
});
|
||||
|
||||
it('leaves disks no loaded agent reports on the per-type thresholds', () => {
|
||||
expect(getPhysicalDiskAlertResourceIds(disk('node-bare'), owners)).toEqual([]);
|
||||
expect(getPhysicalDiskAlertResourceIds(disk('vm-not-loaded'), owners)).toEqual([]);
|
||||
expect(getPhysicalDiskAlertResourceIds(disk(undefined), owners)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,7 @@ describe('useStoragePoolDetailModel', () => {
|
||||
devPath: '/dev/sda',
|
||||
model: 'Disk A',
|
||||
diskType: '',
|
||||
alertResourceIds: [],
|
||||
temperature: 44,
|
||||
hasIssue: false,
|
||||
errorCount: 0,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getExplicitAgentIdFromResource } from '@/utils/agentResources';
|
||||
|
||||
/**
|
||||
* Indexes the resources a physical disk can reach the machine that reports it
|
||||
* through: machines running a Pulse agent, and the storage pools disks are
|
||||
* parented to.
|
||||
*/
|
||||
export const indexPhysicalDiskOwners = (resources: readonly Resource[]): Map<string, Resource> => {
|
||||
const owners = new Map<string, Resource>();
|
||||
for (const resource of resources) {
|
||||
if (resource.type === 'storage' || getExplicitAgentIdFromResource(resource)) {
|
||||
owners.set(resource.id, resource);
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
};
|
||||
|
||||
const uniqueKeys = (...keys: Array<string | undefined>): string[] => [
|
||||
...new Set(keys.map((key) => key?.trim() ?? '').filter((key) => key.length > 0)),
|
||||
];
|
||||
|
||||
/**
|
||||
* The alert override keys of a machine running a Pulse agent, in the order
|
||||
* CheckHost reads them for that agent: its own ID, the machine's canonical
|
||||
* resource ID (which the backend resolves the agent ID to), then the Proxmox
|
||||
* node or guest the agent is merged with. The first key with an override
|
||||
* decides.
|
||||
*/
|
||||
export const getAgentMachineAlertOverrideKeys = (machine: Resource): string[] => {
|
||||
const linkedGuestKeys =
|
||||
machine.type === 'vm' || machine.type === 'system-container'
|
||||
? guestOverrideIdCandidates(machine)
|
||||
: [];
|
||||
return uniqueKeys(
|
||||
getExplicitAgentIdFromResource(machine),
|
||||
machine.id,
|
||||
machine.type === 'agent' ? machine.proxmox?.sourceId : undefined,
|
||||
...linkedGuestKeys,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The alert override keys that judge a physical disk's heat: those of the
|
||||
* machine whose Pulse agent reports the disk, which its disk temperature
|
||||
* alerts are evaluated under. That is the agent machine the disk is parented
|
||||
* to or, for an Unraid array or cache disk parented to its pool, the pool's
|
||||
* machine. A Disk Temp override set on that machine therefore replaces the
|
||||
* per-type thresholds for every disk it reports. Empty when no agent reports
|
||||
* the disk or its machine is not loaded, which leaves the per-type thresholds.
|
||||
*/
|
||||
export const getPhysicalDiskAlertResourceIds = (
|
||||
disk: Resource,
|
||||
owners: ReadonlyMap<string, Resource>,
|
||||
): string[] => {
|
||||
let parentId = disk.parentId?.trim();
|
||||
for (let hops = 0; parentId && hops < 2; hops += 1) {
|
||||
const parent = owners.get(parentId);
|
||||
if (!parent) break;
|
||||
if (getExplicitAgentIdFromResource(parent)) return getAgentMachineAlertOverrideKeys(parent);
|
||||
if (parent.type !== 'storage') break;
|
||||
parentId = parent.parentId?.trim();
|
||||
}
|
||||
return [];
|
||||
};
|
||||
@@ -17,6 +17,8 @@ import { resolvePhysicalDiskHistoryResourceId } from './diskResourceUtils';
|
||||
|
||||
type UseDiskDetailModelOptions = {
|
||||
disk: Accessor<Resource>;
|
||||
/** Alert override keys of the machine whose agent reports the disk. */
|
||||
alertResourceIds?: Accessor<string[] | undefined>;
|
||||
};
|
||||
|
||||
export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
||||
@@ -24,7 +26,11 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
|
||||
const diskData = createMemo<PhysicalDiskPresentationData>(() =>
|
||||
extractPhysicalDiskPresentationData(options.disk(), getDiskTemperatureThresholds),
|
||||
extractPhysicalDiskPresentationData(
|
||||
options.disk(),
|
||||
getDiskTemperatureThresholds,
|
||||
options.alertResourceIds?.(),
|
||||
),
|
||||
);
|
||||
const historyResourceId = createMemo(() => resolvePhysicalDiskHistoryResourceId(options.disk()));
|
||||
const attributeCards = createMemo(() =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildPhysicalDiskRoleFilterOptions,
|
||||
extractPhysicalDiskPresentationData,
|
||||
filterAndSortPhysicalDisks,
|
||||
type PhysicalDiskAlertResourceIdResolver,
|
||||
type PhysicalDiskPresentationData,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
@@ -14,6 +15,7 @@ import { matchesPhysicalDiskNode } from './diskResourceUtils';
|
||||
|
||||
type UseDiskListModelOptions = {
|
||||
disks: () => Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
nodes: () => Resource[];
|
||||
selectedNode: () => string | null;
|
||||
sourceFilter?: () => string;
|
||||
@@ -30,16 +32,25 @@ export const useDiskListModel = (options: UseDiskListModelOptions) => {
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
|
||||
// Each disk's heat is judged by the user's alert disk temperature thresholds,
|
||||
// so the presentation data recomputes when the alert configuration loads.
|
||||
// under the Disk Temp override of the machine that reports it, so the
|
||||
// presentation data recomputes when the alert configuration loads.
|
||||
const diskDataById = createMemo(() =>
|
||||
buildPhysicalDiskPresentationDataMap(options.disks(), getDiskTemperatureThresholds),
|
||||
buildPhysicalDiskPresentationDataMap(
|
||||
options.disks(),
|
||||
getDiskTemperatureThresholds,
|
||||
options.getDiskAlertResourceIds,
|
||||
),
|
||||
);
|
||||
const roleFilterOptions = createMemo(() => buildPhysicalDiskRoleFilterOptions(options.disks()));
|
||||
const groupFilterOptions = createMemo(() => buildPhysicalDiskGroupFilterOptions(options.disks()));
|
||||
|
||||
const getDiskData = (disk: Resource): PhysicalDiskPresentationData =>
|
||||
diskDataById().get(disk.id) ??
|
||||
extractPhysicalDiskPresentationData(disk, getDiskTemperatureThresholds);
|
||||
extractPhysicalDiskPresentationData(
|
||||
disk,
|
||||
getDiskTemperatureThresholds,
|
||||
options.getDiskAlertResourceIds?.(disk),
|
||||
);
|
||||
|
||||
const selectedNodeResource = createMemo(
|
||||
() => options.nodes().find((node) => node.id === options.selectedNode()) ?? null,
|
||||
|
||||
@@ -66,6 +66,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
storageResources,
|
||||
nodes,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
cephResources,
|
||||
alertsEnabled,
|
||||
} = useStoragePageResources({ resourceSource: options.resourceSource });
|
||||
@@ -471,6 +472,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
initialDataReceived: surfaceInitialDataReceived,
|
||||
nodeOptions,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
nodes,
|
||||
groupedRecords,
|
||||
expandedGroups,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Resource, ResourceType } from '@/types/resource';
|
||||
import { getPhysicalDiskAlertResourceIds, indexPhysicalDiskOwners } from './physicalDiskAlertOwner';
|
||||
|
||||
const STORAGE_PAGE_RESOURCES_QUERY = 'type=agent,pbs,storage,physical_disk,ceph';
|
||||
|
||||
@@ -42,6 +43,11 @@ export const useStoragePageResources = (options: UseStoragePageResourcesOptions
|
||||
...resourceSource,
|
||||
resources: storageResourceRows,
|
||||
};
|
||||
// A disk's heat is judged under the alert overrides of the machine whose
|
||||
// agent reports it; this page loads those machines and pools with the disks.
|
||||
const physicalDiskOwners = createMemo(() => indexPhysicalDiskOwners(resourceSource.resources()));
|
||||
const getDiskAlertResourceIds = (disk: Resource): string[] =>
|
||||
getPhysicalDiskAlertResourceIds(disk, physicalDiskOwners());
|
||||
const alertsEnabled = alertsActivation.detectionEnabled;
|
||||
|
||||
return {
|
||||
@@ -53,6 +59,7 @@ export const useStoragePageResources = (options: UseStoragePageResourcesOptions
|
||||
reconnect,
|
||||
nodes,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
cephResources,
|
||||
storageResources,
|
||||
alertsEnabled,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Accessor, createMemo, createSignal } from 'solid-js';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
buildStoragePoolDetailConfigRows,
|
||||
buildStoragePoolDetailTopologyRows,
|
||||
@@ -13,6 +14,7 @@ import type { Resource } from '@/types/resource';
|
||||
type UseStoragePoolDetailModelOptions = {
|
||||
record: Accessor<StorageRecord>;
|
||||
physicalDisks: Accessor<Resource[]>;
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
};
|
||||
|
||||
export const useStoragePoolDetailModel = (options: UseStoragePoolDetailModelOptions) => {
|
||||
@@ -22,7 +24,11 @@ export const useStoragePoolDetailModel = (options: UseStoragePoolDetailModelOpti
|
||||
const configRows = createMemo(() => buildStoragePoolDetailConfigRows(options.record()));
|
||||
const zfsSummary = createMemo(() => buildStoragePoolDetailZfsSummary(options.record()));
|
||||
const linkedDisks = createMemo(() =>
|
||||
getStoragePoolLinkedDisks(options.record(), options.physicalDisks()),
|
||||
getStoragePoolLinkedDisks(
|
||||
options.record(),
|
||||
options.physicalDisks(),
|
||||
options.getDiskAlertResourceIds,
|
||||
),
|
||||
);
|
||||
const topologyRows = createMemo(() =>
|
||||
buildStoragePoolDetailTopologyRows(options.record(), linkedDisks()),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from './guestDrawerModel';
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness';
|
||||
import { getWorkloadAlertResourceIdCandidates } from './workloadTopology';
|
||||
import { RaidCard } from '@/components/shared/cards/RaidCard';
|
||||
|
||||
import type { GuestDrawerProps } from './guestDrawerModel';
|
||||
@@ -326,7 +327,14 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<GuestPhysicalDisks parentId={props.guest.canonicalResourceId ?? props.guest.id} />
|
||||
<GuestPhysicalDisks
|
||||
parentId={props.guest.canonicalResourceId ?? props.guest.id}
|
||||
alertResourceIds={[
|
||||
...(props.guest.agentId ? [props.guest.agentId] : []),
|
||||
...(props.guest.canonicalResourceId ? [props.guest.canonicalResourceId] : []),
|
||||
...getWorkloadAlertResourceIdCandidates(props.guest),
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={props.guest.agentRaid?.length}>
|
||||
|
||||
@@ -19,10 +19,15 @@ const LazyDiskDetail = lazy(() =>
|
||||
import('@/components/Storage/DiskDetail').then(({ DiskDetail }) => ({ default: DiskDetail })),
|
||||
);
|
||||
|
||||
const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => {
|
||||
const GuestPhysicalDiskRow: Component<{ disk: Resource; alertResourceIds: string[] }> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
const data = () => extractPhysicalDiskPresentationData(props.disk, getDiskTemperatureThresholds);
|
||||
const data = () =>
|
||||
extractPhysicalDiskPresentationData(
|
||||
props.disk,
|
||||
getDiskTemperatureThresholds,
|
||||
props.alertResourceIds,
|
||||
);
|
||||
const health = () => getPhysicalDiskHealthStatus(data());
|
||||
const temperature = () => getPhysicalDiskTemperaturePresentation(data());
|
||||
const label = () => data().model || data().devPath || props.disk.name;
|
||||
@@ -68,7 +73,11 @@ const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => {
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<LazyDiskDetail disk={props.disk} nodes={[]} />
|
||||
<LazyDiskDetail
|
||||
disk={props.disk}
|
||||
nodes={[]}
|
||||
alertResourceIds={props.alertResourceIds}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -76,7 +85,15 @@ const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const GuestPhysicalDisks: Component<{ parentId: string }> = (props) => {
|
||||
export const GuestPhysicalDisks: Component<{
|
||||
parentId: string;
|
||||
/**
|
||||
* Alert override keys judging these disks' heat, in the order the backend
|
||||
* reads them: the guest's own agent reports them, so its Disk Temp override
|
||||
* applies, then the guest's canonical ID and override keys.
|
||||
*/
|
||||
alertResourceIds?: string[];
|
||||
}> = (props) => {
|
||||
// The Proxmox Overview snapshot deliberately excludes physical disks. Query
|
||||
// only the open guest's direct children rather than hydrating every disk in
|
||||
// a large estate (or mistaking the VMID-based table key for the resource ID).
|
||||
@@ -104,7 +121,11 @@ export const GuestPhysicalDisks: Component<{ parentId: string }> = (props) => {
|
||||
Physical Disks & SMART ({disks().length})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<For each={disks()}>{(disk) => <GuestPhysicalDiskRow disk={disk} />}</For>
|
||||
<For each={disks()}>
|
||||
{(disk) => (
|
||||
<GuestPhysicalDiskRow disk={disk} alertResourceIds={props.alertResourceIds ?? []} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</InfoCardFrame>
|
||||
</Show>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeAll, beforeEach, 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 type { Resource } from '@/types/resource';
|
||||
import { GuestPhysicalDisks } from '../GuestPhysicalDisks';
|
||||
|
||||
@@ -83,6 +87,38 @@ describe('GuestPhysicalDisks', () => {
|
||||
expect(within(card).getAllByTestId('guest-physical-disk')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("judges the guest's disks by its own agent's Disk Temp override", async () => {
|
||||
const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
activationState: 'active',
|
||||
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
|
||||
diskTempByType: { sata: { trigger: 55, clear: 50 } },
|
||||
overrides: { 'host-guest-101': { diskTemperature: { trigger: 65, clear: 60 } } },
|
||||
} as unknown as AlertConfig);
|
||||
try {
|
||||
queryState.resources = [
|
||||
{ ...disk, physicalDisk: { ...disk.physicalDisk!, temperature: 58 } } as Resource,
|
||||
];
|
||||
await useAlertsActivation().refreshConfig();
|
||||
const guestCard = render(() => (
|
||||
<GuestPhysicalDisks parentId="vm-resource-101" alertResourceIds={['host-guest-101']} />
|
||||
));
|
||||
// 58C passes the 55C SATA trigger but not the agent's 65C override.
|
||||
await waitFor(() =>
|
||||
expect(within(guestCard.container).getByText('Healthy')).toBeInTheDocument(),
|
||||
);
|
||||
guestCard.unmount();
|
||||
|
||||
const unmatched = render(() => (
|
||||
<GuestPhysicalDisks parentId="vm-resource-101" alertResourceIds={['host-other']} />
|
||||
));
|
||||
expect(within(unmatched.container).getByText('Running Hot')).toBeInTheDocument();
|
||||
} finally {
|
||||
getConfig.mockRestore();
|
||||
eventBus.emit('org_switched', 'default');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not add an empty storage card to an uninstrumented guest', () => {
|
||||
render(() => <GuestPhysicalDisks parentId="vm-resource-102" />);
|
||||
expect(screen.queryByTestId('guest-physical-disks')).toBeNull();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AlertConfig } from '@/types/alerts';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
buildPhysicalDiskPresentationDataMap,
|
||||
@@ -401,6 +402,31 @@ describe('diskPresentation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('judges a disk under the Disk Temp override of the machine that reports it', () => {
|
||||
const config = {
|
||||
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
|
||||
diskTempByType: { nvme: { trigger: 70, clear: 65 } },
|
||||
overrides: { 'host-raised': { diskTemperature: { trigger: 80, clear: 75 } } },
|
||||
} as unknown as AlertConfig;
|
||||
const resolve = (diskType: string, alertResourceIds?: string[]) =>
|
||||
resolveDiskTemperatureDisplayThresholds(config, diskType, alertResourceIds);
|
||||
const raisedHostDisk = buildDisk('nvme0n1', { diskType: 'nvme', temperature: 72 });
|
||||
const plainHostDisk = buildDisk('nvme1n1', { diskType: 'nvme', temperature: 72 });
|
||||
const dataMap = buildPhysicalDiskPresentationDataMap(
|
||||
[raisedHostDisk, plainHostDisk],
|
||||
resolve,
|
||||
(disk) => (disk === raisedHostDisk ? ['host-raised'] : ['host-plain']),
|
||||
);
|
||||
|
||||
expect(dataMap.get('nvme0n1')!.temperatureThresholds).toEqual({ warning: 75, critical: 80 });
|
||||
expect(getPhysicalDiskHealthStatus(dataMap.get('nvme0n1')!).label).toBe('Healthy');
|
||||
expect(getPhysicalDiskHealthStatus(dataMap.get('nvme1n1')!).label).toBe('Running Hot');
|
||||
expect(
|
||||
extractPhysicalDiskPresentationData(raisedHostDisk, resolve, ['host-raised'])
|
||||
.temperatureThresholds,
|
||||
).toEqual({ warning: 75, critical: 80 });
|
||||
});
|
||||
|
||||
it('calls no disk hot when its temperature alerting is off', () => {
|
||||
const off = () => null;
|
||||
const data = extractPhysicalDiskPresentationData(buildDisk('sda', { temperature: 80 }), off);
|
||||
|
||||
+6
-1
@@ -231,6 +231,7 @@ describe('storagePoolDetailPresentation', () => {
|
||||
state: 'online',
|
||||
sizeLabel: '1000 B',
|
||||
diskType: '',
|
||||
alertResourceIds: [],
|
||||
temperature: 44,
|
||||
hasIssue: true,
|
||||
spunDown: false,
|
||||
@@ -313,9 +314,13 @@ describe('storagePoolDetailPresentation', () => {
|
||||
},
|
||||
] as Resource[];
|
||||
|
||||
const linkedDisks = getStoragePoolLinkedDisks(record, disks);
|
||||
const linkedDisks = getStoragePoolLinkedDisks(record, disks, (disk) => [`owner-of-${disk.id}`]);
|
||||
|
||||
expect(linkedDisks.map((disk) => disk.role)).toEqual(['parity', 'data']);
|
||||
// Each linked disk carries the override keys its Temp colour is judged under.
|
||||
expect(linkedDisks.map((disk) => disk.alertResourceIds)).toEqual(
|
||||
linkedDisks.map((disk) => [`owner-of-${disk.id}`]),
|
||||
);
|
||||
expect(linkedDisks[0].temperatureLastKnownTitle).toBeUndefined();
|
||||
expect(linkedDisks[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -76,9 +76,10 @@ export interface PhysicalDiskPresentationData {
|
||||
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.
|
||||
* The alert disk temperature thresholds for this disk's type, or the Disk
|
||||
* Temp override of the machine whose agent reports it. 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;
|
||||
@@ -410,14 +411,23 @@ export function getPhysicalDiskHostLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolves the alert disk temperature thresholds for a disk type, under the
|
||||
* alert override keys of the machine whose agent reports the disk. 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,
|
||||
alertResourceIds?: string[],
|
||||
) => MetricDisplayThresholds | null;
|
||||
|
||||
/**
|
||||
* Returns the alert override keys of the machine whose agent reports a disk,
|
||||
* so that machine's Disk Temp override judges the disk's heat. Empty when no
|
||||
* agent reports it.
|
||||
*/
|
||||
export type PhysicalDiskAlertResourceIdResolver = (disk: Resource) => string[];
|
||||
|
||||
const resolveFactoryDiskTemperatureThresholds: PhysicalDiskTemperatureThresholdResolver = (
|
||||
diskType,
|
||||
) => resolveDiskTemperatureDisplayThresholds(null, diskType);
|
||||
@@ -433,6 +443,7 @@ export const getPhysicalDiskTemperatureThresholds = (
|
||||
export function extractPhysicalDiskPresentationData(
|
||||
resource: Resource,
|
||||
resolveTemperatureThresholds: PhysicalDiskTemperatureThresholdResolver = resolveFactoryDiskTemperatureThresholds,
|
||||
alertResourceIds: string[] = [],
|
||||
): PhysicalDiskPresentationData {
|
||||
const pd = resource.physicalDisk || ((resource.platformData as any)?.physicalDisk ?? {});
|
||||
const diskNode = getPhysicalDiskNodeIdentity(resource);
|
||||
@@ -467,7 +478,7 @@ export function extractPhysicalDiskPresentationData(
|
||||
health: pd.health || 'UNKNOWN',
|
||||
wearout: pd.wearout ?? -1,
|
||||
temperature: pd.temperature ?? 0,
|
||||
temperatureThresholds: resolveTemperatureThresholds(pd.diskType || ''),
|
||||
temperatureThresholds: resolveTemperatureThresholds(pd.diskType || '', alertResourceIds),
|
||||
rpm: pd.rpm ?? 0,
|
||||
used: pd.used || '',
|
||||
storageRole: pd.storageRole,
|
||||
@@ -541,10 +552,18 @@ export function getPhysicalDiskCollectionMessages(disk: PhysicalDiskPresentation
|
||||
export function buildPhysicalDiskPresentationDataMap(
|
||||
disks: Resource[],
|
||||
resolveTemperatureThresholds?: PhysicalDiskTemperatureThresholdResolver,
|
||||
getAlertResourceIds?: PhysicalDiskAlertResourceIdResolver,
|
||||
): Map<string, PhysicalDiskPresentationData> {
|
||||
const map = new Map<string, PhysicalDiskPresentationData>();
|
||||
for (const disk of disks || []) {
|
||||
map.set(disk.id, extractPhysicalDiskPresentationData(disk, resolveTemperatureThresholds));
|
||||
map.set(
|
||||
disk.id,
|
||||
extractPhysicalDiskPresentationData(
|
||||
disk,
|
||||
resolveTemperatureThresholds,
|
||||
getAlertResourceIds?.(disk),
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { HistoryTimeRange, ResourceType as HistoryChartResourceType } from '@/api/charts';
|
||||
import { getPhysicalDiskTemperaturePresentation } from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
getPhysicalDiskTemperaturePresentation,
|
||||
type PhysicalDiskAlertResourceIdResolver,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
getStorageRecordContent,
|
||||
@@ -27,6 +30,8 @@ export type StoragePoolDetailLinkedDisk = {
|
||||
state: string;
|
||||
sizeLabel: string;
|
||||
diskType: string;
|
||||
/** Alert override keys of the machine whose agent reports the disk. */
|
||||
alertResourceIds?: string[];
|
||||
temperature: number;
|
||||
/** Set only when the temperature is a retained, not current, reading. */
|
||||
temperatureLastKnownTitle?: string;
|
||||
@@ -402,6 +407,7 @@ const normalizeDevicePath = (value: string): string => value.trim().replace(/^\/
|
||||
export function getStoragePoolLinkedDisks(
|
||||
record: StorageRecord,
|
||||
physicalDisks: Resource[],
|
||||
getAlertResourceIds?: PhysicalDiskAlertResourceIdResolver,
|
||||
): StoragePoolDetailLinkedDisk[] {
|
||||
const pool = getStorageRecordZfsPool(record);
|
||||
const unraidStorageGroup = getUnraidStorageGroup(record).toLowerCase();
|
||||
@@ -432,6 +438,7 @@ export function getStoragePoolLinkedDisks(
|
||||
state: readDiskState(disk),
|
||||
sizeLabel: readDiskSizeLabel(disk),
|
||||
diskType: readDiskType(disk),
|
||||
alertResourceIds: getAlertResourceIds?.(disk) ?? [],
|
||||
temperature: readDiskTemperature(disk),
|
||||
temperatureLastKnownTitle: readDiskTemperatureLastKnownTitle(disk),
|
||||
hasIssue: readDiskHasIssue(disk),
|
||||
|
||||
@@ -99,11 +99,12 @@ describe('useWorkloads', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves linked-agent RAID arrays from the VM resource for guest detail', async () => {
|
||||
it('preserves linked-agent RAID arrays and agent ID from the VM resource for guest detail', async () => {
|
||||
const [snapshot] = createSignal([
|
||||
{
|
||||
...sampleResource,
|
||||
agent: {
|
||||
agentId: 'host-guest-agent',
|
||||
agentVersion: '6.4.5',
|
||||
raid: [
|
||||
{
|
||||
@@ -130,6 +131,8 @@ describe('useWorkloads', () => {
|
||||
try {
|
||||
await flushAsync();
|
||||
expect(result!.workloads()[0]?.canonicalResourceId).toBe(sampleResource.id);
|
||||
// The guest drawer judges the agent's disks under this ID's overrides.
|
||||
expect(result!.workloads()[0]?.agentId).toBe('host-guest-agent');
|
||||
expect(result!.workloads()[0]?.agentRaid).toMatchObject([
|
||||
{ device: '/dev/md0', level: 'raid1', state: 'clean' },
|
||||
]);
|
||||
|
||||
@@ -137,6 +137,7 @@ type APIResource = {
|
||||
vcpus?: number;
|
||||
};
|
||||
agent?: {
|
||||
agentId?: string;
|
||||
hostname?: string;
|
||||
uptimeSeconds?: number;
|
||||
osName?: string;
|
||||
@@ -608,6 +609,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
osVersion: resource.agent?.osVersion ?? resource.proxmox?.osVersion,
|
||||
agentVersion: resource.agent?.agentVersion,
|
||||
agentKind: resource.agent?.agentVersion ? 'pulse' : undefined,
|
||||
agentId: resource.agent?.agentId,
|
||||
agentRaid: resource.agent?.raid,
|
||||
networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces),
|
||||
networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0,
|
||||
|
||||
@@ -56,6 +56,8 @@ export type WorkloadGuest = (VM | Container) & {
|
||||
containerRuntime?: string;
|
||||
/** Identifies the producer of agentVersion instead of inferring it from VM type. */
|
||||
agentKind?: 'pulse' | 'qemu-guest';
|
||||
/** ID of the Pulse agent running in this guest, the key of its alert overrides. */
|
||||
agentId?: string;
|
||||
/** RAID arrays reported by the linked Pulse agent, not Proxmox virtual disks. */
|
||||
agentRaid?: HostRAIDArray[];
|
||||
updateStatus?: DockerContainerUpdateStatus;
|
||||
|
||||
@@ -397,6 +397,50 @@ describe('metricThresholds', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('judges a disk by the alert overrides of the machine that reports it', () => {
|
||||
const config = {
|
||||
enabled: true,
|
||||
guestDefaults: {},
|
||||
nodeDefaults: {},
|
||||
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
|
||||
diskTempByType: { nvme: { trigger: 70, clear: 65 } },
|
||||
storageDefault: { trigger: 85, clear: 80 },
|
||||
overrides: {
|
||||
'host-raised': { diskTemperature: { trigger: 80, clear: 75 } },
|
||||
'host-memory-only': { memory: { trigger: 95, clear: 90 } },
|
||||
'host-off': { disabled: true },
|
||||
},
|
||||
} as AlertConfig;
|
||||
// The machine's first matching key decides, as CheckHost's chain does.
|
||||
expect(
|
||||
resolveDiskTemperatureDisplayThresholds(config, 'nvme', [
|
||||
'agent:host-raised',
|
||||
'host-raised',
|
||||
]),
|
||||
).toEqual({ warning: 75, critical: 80 });
|
||||
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme', ['host-memory-only'])).toEqual(
|
||||
{
|
||||
warning: 65,
|
||||
critical: 70,
|
||||
},
|
||||
);
|
||||
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme', ['host-plain'])).toEqual({
|
||||
warning: 65,
|
||||
critical: 70,
|
||||
});
|
||||
// A host whose alerts are switched off raises no disk temperature alert,
|
||||
// so none of its disks is judged hot.
|
||||
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme', ['host-off'])).toBeNull();
|
||||
// An agent default that switches every host's alerts off does the same.
|
||||
const agentsOff = {
|
||||
...config,
|
||||
agentDefaults: { ...config.agentDefaults, disabled: true },
|
||||
} as AlertConfig;
|
||||
expect(
|
||||
resolveDiskTemperatureDisplayThresholds(agentsOff, 'nvme', ['host-raised']),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to seeded per-type disk temperature defaults without config', () => {
|
||||
expect(resolveDiskTemperatureDisplayThresholds(null, 'nvme')).toEqual({
|
||||
warning: 65,
|
||||
|
||||
@@ -271,7 +271,10 @@ export const resolveMetricDisplayThresholds = (
|
||||
* host (or inherited linked resource) wins, then the per-type map
|
||||
* (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.
|
||||
* alerts do, and so does an override (or agent default) that switches the
|
||||
* host's alerts off. Null means disk temperature alerting is off for this
|
||||
* disk. `resourceIds` are the alert override keys of the machine whose agent
|
||||
* reports the disk, in the order the backend reads them.
|
||||
*/
|
||||
export const resolveDiskTemperatureDisplayThresholds = (
|
||||
config: AlertConfig | null,
|
||||
@@ -282,6 +285,9 @@ export const resolveDiskTemperatureDisplayThresholds = (
|
||||
const normalizedType = (diskType ?? '').trim().toLowerCase();
|
||||
|
||||
const override = findOverride(config?.overrides, resourceIds);
|
||||
if (override?.disabled || config?.agentDefaults?.disabled === true) {
|
||||
return null;
|
||||
}
|
||||
const overrideValue = getOverrideValue(override, 'diskTemperature');
|
||||
if (overrideValue !== undefined) {
|
||||
return resolveThreshold(overrideValue, FACTORY_AGENT_DEFAULTS.diskTemperature, margin);
|
||||
|
||||
@@ -69,12 +69,13 @@ func (a *AlertThresholdAdapter) GetGuestDiskThreshold() float64 {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// clear values for a disk of a type that a host agent reports, resolved
|
||||
// exactly as disk temperature alerts resolve them
|
||||
// (alerts.Manager.HostDiskTemperatureThreshold), host overrides included.
|
||||
func (a *AlertThresholdAdapter) GetDiskTemperatureThreshold(host alerts.DiskTemperatureHost, diskType string) (float64, float64) {
|
||||
var threshold *alerts.HysteresisThreshold
|
||||
if a.manager != nil {
|
||||
threshold = a.manager.DiskTemperatureThreshold(diskType)
|
||||
threshold = a.manager.HostDiskTemperatureThreshold(host, diskType)
|
||||
} else {
|
||||
threshold = alerts.DefaultDiskTemperatureThreshold(diskType)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestAlertThresholdAdapter_Fallbacks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertThresholdAdapter_DiskTemperatureFollowsAlertPolicy(t *testing.T) {
|
||||
if trigger, clear := NewAlertThresholdAdapter(nil).GetDiskTemperatureThreshold("nvme"); trigger != 70 || clear != 65 {
|
||||
if trigger, clear := NewAlertThresholdAdapter(nil).GetDiskTemperatureThreshold(alerts.DiskTemperatureHost{}, "nvme"); trigger != 70 || clear != 65 {
|
||||
t.Fatalf("no manager: nvme = %v/%v, want the factory 70/65", trigger, clear)
|
||||
}
|
||||
|
||||
@@ -93,17 +93,42 @@ func TestAlertThresholdAdapter_DiskTemperatureFollowsAlertPolicy(t *testing.T) {
|
||||
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 {
|
||||
if trigger, clear := a.GetDiskTemperatureThreshold(alerts.DiskTemperatureHost{}, "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 {
|
||||
if trigger, clear := a.GetDiskTemperatureThreshold(alerts.DiskTemperatureHost{}, "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 {
|
||||
if trigger, clear := a.GetDiskTemperatureThreshold(alerts.DiskTemperatureHost{}, "nvme"); trigger != 0 || clear != 0 {
|
||||
t.Fatalf("disk temperature alerting off: nvme = %v/%v, want 0/0", trigger, clear)
|
||||
}
|
||||
}
|
||||
|
||||
// A host's Disk Temp override reaches Patrol for that host's disks only, and a
|
||||
// host whose alerts are switched off has no disk heat policy at all.
|
||||
func TestAlertThresholdAdapter_DiskTemperatureHonoursHostOverrides(t *testing.T) {
|
||||
mgr := alerts.NewManager()
|
||||
cfg := mgr.GetConfig()
|
||||
cfg.Overrides = map[string]alerts.ThresholdConfig{
|
||||
"host-raised": {DiskTemperature: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75}},
|
||||
"host-off": {Disabled: true},
|
||||
}
|
||||
mgr.UpdateConfig(cfg)
|
||||
a := NewAlertThresholdAdapter(mgr)
|
||||
|
||||
for host, want := range map[string][2]float64{
|
||||
"host-raised": {80, 75},
|
||||
"host-plain": {70, 65},
|
||||
"host-off": {0, 0},
|
||||
"": {70, 65},
|
||||
} {
|
||||
trigger, clear := a.GetDiskTemperatureThreshold(alerts.DiskTemperatureHost{ID: host}, "nvme")
|
||||
if trigger != want[0] || clear != want[1] {
|
||||
t.Errorf("host %q nvme = %v/%v, want %v/%v", host, trigger, clear, want[0], want[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ 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) {
|
||||
func (m *mockThresholdProvider) GetDiskTemperatureThreshold(_ alerts.DiskTemperatureHost, diskType string) (float64, float64) {
|
||||
if limits, ok := m.diskTemperature[diskType]; ok {
|
||||
return limits[0], limits[1]
|
||||
}
|
||||
|
||||
@@ -67,9 +67,10 @@ type ThresholdProvider interface {
|
||||
// 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)
|
||||
// and clear values (°C) for a disk of the given type that the given host
|
||||
// agent reports. A host with no ID means no agent reports the disk. A zero
|
||||
// trigger means disk temperature alerting is off for it.
|
||||
GetDiskTemperatureThreshold(host alerts.DiskTemperatureHost, diskType string) (trigger, clear float64)
|
||||
}
|
||||
|
||||
// AlertResolver provides the ability to review and resolve alerts
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
||||
"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/servicediscovery"
|
||||
@@ -2447,6 +2448,10 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
|
||||
if urp != nil {
|
||||
diskResources := urp.GetByType(unifiedresources.ResourceTypePhysicalDisk)
|
||||
rows := make([]patrolPhysicalDiskRow, 0, len(diskResources))
|
||||
var owners map[string]unifiedresources.Resource
|
||||
if len(diskResources) > 0 {
|
||||
owners = physicalDiskOwnerIndex(urp)
|
||||
}
|
||||
for _, r := range diskResources {
|
||||
if !seedIsInScope(scopedSet, r.ID) || r.PhysicalDisk == nil {
|
||||
continue
|
||||
@@ -2479,7 +2484,7 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
|
||||
temperature: tools.SplitDiskTemperature(r.PhysicalDisk.Temperature, r.PhysicalDisk.Collection),
|
||||
smartEvidence: unifiedPhysicalDiskSMARTIssueParts(r.PhysicalDisk.SMART),
|
||||
}
|
||||
row.temperatureLimits = snap.diskTemperatureLimits(row.diskType)
|
||||
row.temperatureLimits = snap.diskTemperatureLimits(physicalDiskTemperatureHost(r, owners), row.diskType)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
@@ -2522,7 +2527,7 @@ func patrolPhysicalDiskRows(snap patrolRuntimeState, scopedSet map[string]bool)
|
||||
temperature: tools.SplitDiskTemperature(d.Temperature, d.Collection),
|
||||
smartEvidence: modelPhysicalDiskSMARTIssueParts(d.SmartAttributes),
|
||||
}
|
||||
row.temperatureLimits = snap.diskTemperatureLimits(row.diskType)
|
||||
row.temperatureLimits = snap.diskTemperatureLimits(alerts.DiskTemperatureHost{}, row.diskType)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package ai
|
||||
|
||||
import "github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
// diskTemperatureLimits is the alert disk temperature policy for one disk
|
||||
// type, the same policy disk temperature alerts and the Physical Disks Health
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// diskTemperatureLimits is the alert disk temperature policy for one disk,
|
||||
// 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.
|
||||
@@ -12,13 +17,14 @@ type diskTemperatureLimits struct {
|
||||
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 {
|
||||
// diskTemperatureLimitsFor resolves the policy for a disk of the given type
|
||||
// that the given host agent reports from the user's alert configuration, so
|
||||
// that host's Disk Temp override applies, or from the factory alert
|
||||
// configuration when Patrol has no threshold provider.
|
||||
func diskTemperatureLimitsFor(provider ThresholdProvider, host alerts.DiskTemperatureHost, diskType string) diskTemperatureLimits {
|
||||
var trigger, clear float64
|
||||
if provider != nil {
|
||||
trigger, clear = provider.GetDiskTemperatureThreshold(diskType)
|
||||
trigger, clear = provider.GetDiskTemperatureThreshold(host, diskType)
|
||||
} else if threshold := alerts.DefaultDiskTemperatureThreshold(diskType); threshold != nil {
|
||||
trigger, clear = threshold.Trigger, threshold.Clear
|
||||
}
|
||||
@@ -48,3 +54,44 @@ func (l diskTemperatureLimits) cooled(temperature int) bool {
|
||||
}
|
||||
return float64(temperature) <= l.clear
|
||||
}
|
||||
|
||||
// physicalDiskOwnerIndex indexes, by resource ID, the resources a physical disk
|
||||
// can reach its reporting host agent through: agent-bearing machines and the
|
||||
// storage pools disks hang off.
|
||||
func physicalDiskOwnerIndex(urp UnifiedResourceProvider) map[string]unifiedresources.Resource {
|
||||
index := make(map[string]unifiedresources.Resource)
|
||||
for _, resource := range urp.GetAll() {
|
||||
if resource.Agent != nil || unifiedresources.CanonicalResourceType(resource.Type) == unifiedresources.ResourceTypeStorage {
|
||||
index[resource.ID] = resource
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// physicalDiskTemperatureHost returns the host agent that reports a physical
|
||||
// disk, the host its disk temperature alerts are evaluated under: the agent on
|
||||
// the machine the disk is parented to or, for a disk parented to a storage
|
||||
// pool (Unraid array and cache disks), the agent on the pool's machine. A disk
|
||||
// no agent reports gets the hostless policy.
|
||||
func physicalDiskTemperatureHost(disk unifiedresources.Resource, owners map[string]unifiedresources.Resource) alerts.DiskTemperatureHost {
|
||||
parentID := disk.ParentID
|
||||
for hops := 0; parentID != nil && hops < 2; hops++ {
|
||||
parent, ok := owners[strings.TrimSpace(*parentID)]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if parent.Agent != nil && strings.TrimSpace(parent.Agent.AgentID) != "" {
|
||||
return alerts.DiskTemperatureHost{
|
||||
ID: strings.TrimSpace(parent.Agent.AgentID),
|
||||
LinkedNodeID: parent.Agent.LinkedNodeID,
|
||||
LinkedVMID: parent.Agent.LinkedVMID,
|
||||
LinkedContainerID: parent.Agent.LinkedContainerID,
|
||||
}
|
||||
}
|
||||
if unifiedresources.CanonicalResourceType(parent.Type) != unifiedresources.ResourceTypeStorage {
|
||||
break
|
||||
}
|
||||
parentID = parent.ParentID
|
||||
}
|
||||
return alerts.DiskTemperatureHost{}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
@@ -33,7 +34,7 @@ func TestDiskTemperatureLimitsFollowTheAlertPolicy(t *testing.T) {
|
||||
{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 {
|
||||
if got := diskTemperatureLimitsFor(tc.provider, alerts.DiskTemperatureHost{}, tc.diskType); got != tc.want {
|
||||
t.Errorf("%s: limits = %+v, want %+v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +172,7 @@ func TestPatrolRunStateCarriesTheConfiguredDiskTemperaturePolicy(t *testing.T) {
|
||||
{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 {
|
||||
if got := ps.currentPatrolRuntimeState().diskTemperatureLimits(alerts.DiskTemperatureHost{}, "nvme"); got.trigger != 70 {
|
||||
t.Fatalf("no provider: nvme trigger = %v, want the factory 70", got.trigger)
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ func TestPatrolRunStateCarriesTheConfiguredDiskTemperaturePolicy(t *testing.T) {
|
||||
"current": ps.currentPatrolRuntimeState(),
|
||||
"snapshot": ps.patrolRuntimeStateForSnapshot(snapshot),
|
||||
} {
|
||||
if got := state.diskTemperatureLimits("nvme"); got.trigger != 75 || got.clear != 70 {
|
||||
if got := state.diskTemperatureLimits(alerts.DiskTemperatureHost{}, "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 {
|
||||
@@ -194,7 +195,7 @@ func TestPatrolRunStateCarriesTheConfiguredDiskTemperaturePolicy(t *testing.T) {
|
||||
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 {
|
||||
if got := scoped.diskTemperatureLimits(alerts.DiskTemperatureHost{}, "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 {
|
||||
@@ -212,19 +213,19 @@ func TestPatrolStartsWithAThresholdProviderSetBeforeIt(t *testing.T) {
|
||||
if patrol == nil {
|
||||
t.Fatal("expected Patrol to start once a state provider exists")
|
||||
}
|
||||
if got := patrol.currentPatrolRuntimeState().diskTemperatureLimits("nvme"); got.trigger != 75 {
|
||||
if got := patrol.currentPatrolRuntimeState().diskTemperatureLimits(alerts.DiskTemperatureHost{}, "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")
|
||||
limits := diskTemperatureLimitsFor(nil, alerts.DiskTemperatureHost{}, "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")
|
||||
noBand := diskTemperatureLimitsFor(&mockThresholdProvider{diskTemperature: map[string][2]float64{"sas": {70, 70}}}, alerts.DiskTemperatureHost{}, "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)
|
||||
}
|
||||
@@ -247,3 +248,98 @@ func TestDiskHeatRecoveryNeedsACurrentReading(t *testing.T) {
|
||||
t.Fatalf("retained 40C: recovered = %v, err = %v, want recovered", recovered, err)
|
||||
}
|
||||
}
|
||||
|
||||
// overriddenAgentDisksRegistry is two host agents, each reporting one NVMe
|
||||
// disk hotter than the NVMe trigger, with a Disk Temp override of 80C (clear
|
||||
// 75) set on agent-cool only. Alerts judge agent-cool's disk by the override.
|
||||
func overriddenAgentDisksRegistry(t *testing.T) (*unifiedresources.ResourceRegistry, *AlertThresholdAdapter) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
agent := func(id string, temperature int) models.Host {
|
||||
return models.Host{
|
||||
ID: id, Hostname: id, DisplayName: id, Status: "online", LastSeen: now, IntervalSeconds: 30,
|
||||
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{
|
||||
Device: "nvme0n1", Model: "Samsung 990", Serial: id + "-serial", Type: "nvme",
|
||||
Health: "PASSED", Temperature: temperature,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
registry := unifiedresources.NewRegistry(nil)
|
||||
registry.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{agent("agent-cool", 76), agent("agent-plain", 72)}})
|
||||
|
||||
mgr := alerts.NewManager()
|
||||
cfg := mgr.GetConfig()
|
||||
cfg.Overrides = map[string]alerts.ThresholdConfig{
|
||||
"agent-cool": {DiskTemperature: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75}},
|
||||
}
|
||||
mgr.UpdateConfig(cfg)
|
||||
return registry, NewAlertThresholdAdapter(mgr)
|
||||
}
|
||||
|
||||
// A host agent's Disk Temp override replaces the per-type trigger for every
|
||||
// disk that agent reports, in Patrol exactly as in its alerts: agent-cool's
|
||||
// NVMe at 76C is under its 80C override, agent-plain's at 72C is over the
|
||||
// NVMe trigger of 70C.
|
||||
func TestPatrolJudgesAgentDisksByTheAgentDiskTemperatureOverride(t *testing.T) {
|
||||
registry, provider := overriddenAgentDisksRegistry(t)
|
||||
state := newPatrolRuntimeStateWithProviders(models.StateSnapshot{}, nil, unifiedresources.NewUnifiedAIAdapter(registry))
|
||||
state.thresholdProvider = provider
|
||||
|
||||
rows := patrolPhysicalDiskRows(state, nil)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %+v, want one disk per agent", rows)
|
||||
}
|
||||
for _, row := range rows {
|
||||
want, wantIssue := diskTemperatureLimits{trigger: 70, clear: 65}, true
|
||||
if row.temperature.Collected == 76 {
|
||||
want, wantIssue = diskTemperatureLimits{trigger: 80, clear: 75}, false
|
||||
}
|
||||
if row.temperatureLimits != want {
|
||||
t.Errorf("disk at %dC: limits = %+v, want %+v", row.temperature.Collected, row.temperatureLimits, want)
|
||||
}
|
||||
if got := patrolPhysicalDiskHealthIssue(row); got != wantIssue {
|
||||
t.Errorf("disk at %dC: health issue = %v, want %v", row.temperature.Collected, got, wantIssue)
|
||||
}
|
||||
}
|
||||
|
||||
s := &Service{}
|
||||
s.SetUnifiedResourceProvider(unifiedresources.NewUnifiedAIAdapter(registry))
|
||||
s.SetPatrolThresholdProvider(provider)
|
||||
_, section, _ := strings.Cut(s.buildUnifiedResourceContext(), "Physical Disks Needing Attention")
|
||||
section, _, _ = strings.Cut(section, "\n\n")
|
||||
if !strings.Contains(section, "Temp: 72C") || strings.Contains(section, "Temp: 76C") {
|
||||
t.Fatalf("attention section = %q, want agent-plain's 72C disk only", section)
|
||||
}
|
||||
}
|
||||
|
||||
// A disk reaches its reporting agent through its parent: the agent's machine
|
||||
// directly, or the storage pool an Unraid array or cache disk hangs off. A
|
||||
// disk on a machine with no agent gets the hostless policy.
|
||||
func TestPhysicalDiskTemperatureHostFollowsTheParentChain(t *testing.T) {
|
||||
ref := func(id string) *string { return &id }
|
||||
owners := map[string]unifiedresources.Resource{
|
||||
"agent-1": {ID: "agent-1", Type: unifiedresources.ResourceTypeAgent, Agent: &unifiedresources.AgentData{
|
||||
AgentID: "host-1", LinkedNodeID: "lab-pve1",
|
||||
}},
|
||||
"vm-1": {ID: "vm-1", Type: unifiedresources.ResourceTypeVM, Agent: &unifiedresources.AgentData{
|
||||
AgentID: "host-vm", LinkedVMID: "lab-pve1-101",
|
||||
}},
|
||||
"array-1": {ID: "array-1", Type: unifiedresources.ResourceTypeStorage, ParentID: ref("agent-1")},
|
||||
"node-1": {ID: "node-1", Type: unifiedresources.ResourceTypeAgent},
|
||||
}
|
||||
for parent, want := range map[string]alerts.DiskTemperatureHost{
|
||||
"agent-1": {ID: "host-1", LinkedNodeID: "lab-pve1"},
|
||||
"vm-1": {ID: "host-vm", LinkedVMID: "lab-pve1-101"},
|
||||
"array-1": {ID: "host-1", LinkedNodeID: "lab-pve1"},
|
||||
"node-1": {},
|
||||
"missing": {},
|
||||
} {
|
||||
disk := unifiedresources.Resource{ID: "disk-under-" + parent, Type: unifiedresources.ResourceTypePhysicalDisk, ParentID: ref(parent)}
|
||||
if got := physicalDiskTemperatureHost(disk, owners); got != want {
|
||||
t.Errorf("disk under %s: host = %+v, want %+v", parent, got, want)
|
||||
}
|
||||
}
|
||||
if got := physicalDiskTemperatureHost(unifiedresources.Resource{ID: "orphan"}, owners); got != (alerts.DiskTemperatureHost{}) {
|
||||
t.Errorf("parentless disk: host = %+v, want hostless", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ type patrolRuntimeState struct {
|
||||
}
|
||||
|
||||
// diskTemperatureLimits resolves the alert disk temperature policy for one
|
||||
// disk type.
|
||||
func (s patrolRuntimeState) diskTemperatureLimits(diskType string) diskTemperatureLimits {
|
||||
return diskTemperatureLimitsFor(s.thresholdProvider, diskType)
|
||||
// disk of the given type that the given host agent reports.
|
||||
func (s patrolRuntimeState) diskTemperatureLimits(host alerts.DiskTemperatureHost, diskType string) diskTemperatureLimits {
|
||||
return diskTemperatureLimitsFor(s.thresholdProvider, host, diskType)
|
||||
}
|
||||
|
||||
func newPatrolRuntimeState(snapshot models.StateSnapshot) patrolRuntimeState {
|
||||
|
||||
@@ -3,6 +3,7 @@ package ai
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ 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) {
|
||||
func (m MockThresholdProvider) GetDiskTemperatureThreshold(alerts.DiskTemperatureHost, string) (float64, float64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
|
||||
@@ -409,6 +409,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
|
||||
|
||||
if len(physicalDisks) > 0 {
|
||||
attention := make([]unifiedresources.Resource, 0)
|
||||
owners := physicalDiskOwnerIndex(urp)
|
||||
for _, disk := range physicalDisks {
|
||||
health := ""
|
||||
hot := false
|
||||
@@ -416,7 +417,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
|
||||
health = strings.ToUpper(strings.TrimSpace(disk.PhysicalDisk.Health))
|
||||
// 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)
|
||||
hot = diskTemperatureLimitsFor(thresholdProvider, physicalDiskTemperatureHost(disk, owners), disk.PhysicalDisk.DiskType).hot(collected)
|
||||
}
|
||||
if disk.Status != unifiedresources.StatusOnline || (health != "" && health != "PASSED" && health != "UNKNOWN") || hot {
|
||||
attention = append(attention, disk)
|
||||
|
||||
+29
-2
@@ -1017,14 +1017,41 @@ func diskTemperatureThresholdForType(byType map[string]HysteresisThreshold, host
|
||||
// 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.
|
||||
// trigger means disk temperature alerting is off. A disk a host agent reports
|
||||
// is judged by HostDiskTemperatureThreshold instead.
|
||||
func (m *Manager) DiskTemperatureThreshold(diskType string) *HysteresisThreshold {
|
||||
return m.HostDiskTemperatureThreshold(DiskTemperatureHost{}, diskType)
|
||||
}
|
||||
|
||||
// DiskTemperatureHost is the host agent whose report carries a physical disk,
|
||||
// with the node and guests that agent is linked to. CheckHost evaluates the
|
||||
// disk's temperature under that host's thresholds.
|
||||
type DiskTemperatureHost struct {
|
||||
ID string
|
||||
LinkedNodeID string
|
||||
LinkedVMID string
|
||||
LinkedContainerID string
|
||||
}
|
||||
|
||||
// HostDiskTemperatureThreshold is the disk heat policy for a disk the given
|
||||
// host agent reports, resolved as CheckHost resolves it. An explicit disk
|
||||
// temperature override on the host, or one inherited from its linked node or
|
||||
// guest, replaces the disk type's entry. An override that switches the host's
|
||||
// alerts off switches its disk heat off too, so the result is nil. A host with
|
||||
// no ID resolves as DiskTemperatureThreshold.
|
||||
func (m *Manager) HostDiskTemperatureThreshold(host DiskTemperatureHost, 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))
|
||||
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
|
||||
if thresholds.Disabled {
|
||||
return nil
|
||||
}
|
||||
override, exists := m.hostThresholdOverrideNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
|
||||
overridden := exists && override.DiskTemperature != nil
|
||||
return cloneThreshold(m.hostDiskTemperatureThresholdNoLock(thresholds.DiskTemperature, overridden, diskType))
|
||||
}
|
||||
|
||||
// DefaultDiskTemperatureThreshold is DiskTemperatureThreshold under the
|
||||
|
||||
@@ -1445,3 +1445,148 @@ func TestDiskTemperatureThresholdMatchesCheckHostPolicy(t *testing.T) {
|
||||
t.Fatalf("nil manager threshold = %+v, want the factory nvme 70", got)
|
||||
}
|
||||
}
|
||||
|
||||
// HostDiskTemperatureThreshold is the disk heat policy for a disk a host agent
|
||||
// reports, so it must agree with CheckHost on every host-level override: an
|
||||
// explicit Disk Temp override on the host (or the canonical resource its ID
|
||||
// resolves to), or inherited from its linked node or guest, replaces the
|
||||
// per-type entry; the first override in the chain decides even when it sets no
|
||||
// Disk Temp; and a host whose alerts are off judges no heat.
|
||||
func TestHostDiskTemperatureThresholdMatchesCheckHostOverrides(t *testing.T) {
|
||||
const hostID = "host-temp-policy"
|
||||
const nodeID = "pve1-node1"
|
||||
const vmID = "pve1-node1-105"
|
||||
const canonicalID = "agent-canonical-temp-policy"
|
||||
perType := &HysteresisThreshold{Trigger: 70, Clear: 65}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
overrides map[string]ThresholdConfig
|
||||
linkNode bool
|
||||
linkVM bool
|
||||
canonical bool
|
||||
want *HysteresisThreshold
|
||||
off bool
|
||||
}{
|
||||
{name: "no override", want: perType},
|
||||
{
|
||||
name: "host disk temp override",
|
||||
overrides: map[string]ThresholdConfig{hostID: {DiskTemperature: &HysteresisThreshold{Trigger: 80, Clear: 75}}},
|
||||
want: &HysteresisThreshold{Trigger: 80, Clear: 75},
|
||||
},
|
||||
{
|
||||
name: "host override without disk temp",
|
||||
overrides: map[string]ThresholdConfig{hostID: {Memory: &HysteresisThreshold{Trigger: 90, Clear: 85}}},
|
||||
want: perType,
|
||||
},
|
||||
{
|
||||
name: "linked node disk temp override",
|
||||
overrides: map[string]ThresholdConfig{nodeID: {DiskTemperature: &HysteresisThreshold{Trigger: 78, Clear: 72}}},
|
||||
linkNode: true,
|
||||
want: &HysteresisThreshold{Trigger: 78, Clear: 72},
|
||||
},
|
||||
{
|
||||
name: "host override shadows linked node",
|
||||
overrides: map[string]ThresholdConfig{
|
||||
hostID: {Memory: &HysteresisThreshold{Trigger: 90, Clear: 85}},
|
||||
nodeID: {DiskTemperature: &HysteresisThreshold{Trigger: 78, Clear: 72}},
|
||||
},
|
||||
linkNode: true,
|
||||
want: perType,
|
||||
},
|
||||
{
|
||||
// The agent ID resolves to the machine's canonical resource ID.
|
||||
name: "canonical machine disk temp override",
|
||||
overrides: map[string]ThresholdConfig{canonicalID: {DiskTemperature: &HysteresisThreshold{Trigger: 82, Clear: 77}}},
|
||||
canonical: true,
|
||||
want: &HysteresisThreshold{Trigger: 82, Clear: 77},
|
||||
},
|
||||
{
|
||||
name: "linked guest disk temp override",
|
||||
overrides: map[string]ThresholdConfig{vmID: {DiskTemperature: &HysteresisThreshold{Trigger: 76, Clear: 71}}},
|
||||
linkVM: true,
|
||||
want: &HysteresisThreshold{Trigger: 76, Clear: 71},
|
||||
},
|
||||
{
|
||||
name: "linked guest alerts disabled",
|
||||
overrides: map[string]ThresholdConfig{vmID: {Disabled: true}},
|
||||
linkVM: true,
|
||||
},
|
||||
{
|
||||
name: "host disk temp switched off",
|
||||
overrides: map[string]ThresholdConfig{hostID: {DiskTemperature: &HysteresisThreshold{}}},
|
||||
off: true,
|
||||
},
|
||||
{
|
||||
name: "host alerts disabled",
|
||||
overrides: map[string]ThresholdConfig{hostID: {Disabled: true}},
|
||||
},
|
||||
{
|
||||
name: "linked node alerts disabled",
|
||||
overrides: map[string]ThresholdConfig{nodeID: {Disabled: true}},
|
||||
linkNode: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := configureDiskTempTypeHostManager(t)
|
||||
m.mu.Lock()
|
||||
for id, override := range tc.overrides {
|
||||
m.config.Overrides[id] = override
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if tc.canonical {
|
||||
m.SetResourceIntentIdentityResolver(func(resourceID string) (string, bool) {
|
||||
return canonicalID, resourceID == hostID
|
||||
})
|
||||
}
|
||||
|
||||
owner := DiskTemperatureHost{ID: hostID}
|
||||
if tc.linkNode {
|
||||
owner.LinkedNodeID = nodeID
|
||||
}
|
||||
if tc.linkVM {
|
||||
owner.LinkedVMID = vmID
|
||||
}
|
||||
got := m.HostDiskTemperatureThreshold(owner, "nvme")
|
||||
switch {
|
||||
case tc.off:
|
||||
if got == nil || got.Trigger > 0 {
|
||||
t.Fatalf("HostDiskTemperatureThreshold = %+v, want a switched-off threshold", got)
|
||||
}
|
||||
case (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want):
|
||||
t.Fatalf("HostDiskTemperatureThreshold = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
|
||||
check := func(temperature int) bool {
|
||||
host := hostWithSMARTDiskTemp(hostID, "nvme", temperature)
|
||||
host.LinkedNodeID = owner.LinkedNodeID
|
||||
host.LinkedVMID = owner.LinkedVMID
|
||||
m.CheckHost(host)
|
||||
_, exists := testLookupActiveAlert(t, m, hostDiskTempAlertID(host))
|
||||
return exists
|
||||
}
|
||||
if got == nil || got.Trigger <= 0 {
|
||||
if check(99) {
|
||||
t.Fatalf("CheckHost alerted at 99C on a host the policy judges no heat for, active: %v", alertKeys(m))
|
||||
}
|
||||
return
|
||||
}
|
||||
trigger := int(got.Trigger)
|
||||
if check(trigger - 1) {
|
||||
t.Fatalf("CheckHost alerted at %dC, under the policy trigger %v", trigger-1, got.Trigger)
|
||||
}
|
||||
if !check(trigger) {
|
||||
t.Fatalf("CheckHost stayed quiet at the policy trigger %dC, active: %v", trigger, alertKeys(m))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
m := configureDiskTempTypeHostManager(t)
|
||||
if got, want := m.HostDiskTemperatureThreshold(DiskTemperatureHost{}, "sas"), m.DiskTemperatureThreshold("sas"); got == nil || want == nil || *got != *want {
|
||||
t.Fatalf("hostless policy = %+v, want DiskTemperatureThreshold %+v", got, want)
|
||||
}
|
||||
var nilManager *Manager
|
||||
if got := nilManager.HostDiskTemperatureThreshold(DiskTemperatureHost{ID: hostID}, "nvme"); got == nil || got.Trigger != 70 {
|
||||
t.Fatalf("nil manager host policy = %+v, want the factory nvme 70", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -67,7 +68,7 @@ func (concurrencyThresholdProvider) GetGuestMemoryThreshold() float64 {
|
||||
}
|
||||
func (concurrencyThresholdProvider) GetGuestDiskThreshold() float64 { return 95 }
|
||||
func (concurrencyThresholdProvider) GetStorageThreshold() float64 { return 92 }
|
||||
func (concurrencyThresholdProvider) GetDiskTemperatureThreshold(string) (float64, float64) {
|
||||
func (concurrencyThresholdProvider) GetDiskTemperatureThreshold(alerts.DiskTemperatureHost, string) (float64, float64) {
|
||||
return 70, 65
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
|
||||
"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"
|
||||
@@ -154,7 +155,7 @@ func (stubThresholdProvider) GetGuestMemoryThreshold() float64 {
|
||||
}
|
||||
func (stubThresholdProvider) GetGuestDiskThreshold() float64 { return 95 }
|
||||
func (stubThresholdProvider) GetStorageThreshold() float64 { return 92 }
|
||||
func (stubThresholdProvider) GetDiskTemperatureThreshold(string) (float64, float64) {
|
||||
func (stubThresholdProvider) GetDiskTemperatureThreshold(alerts.DiskTemperatureHost, string) (float64, float64) {
|
||||
return 70, 65
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user