Integrate reviewed TrueNAS saved-policy disk displays

Retain the exact repaired and composed candidate history, its fresh parent-bound browser receipt and completed containing frontend qualification. Existing Recovery removal and disk-specific backend policy remain intact.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-09 14:48:41 +01:00
23 changed files with 895 additions and 66 deletions
+4 -2
View File
@@ -567,8 +567,10 @@ check that the intended value persisted.
For TrueNAS physical disks, read the [disk temperature and health guide](TRUENAS.md#disk-temperature-and-health)
before changing a rule: heat, SMART faults and missing readings are different
signals, and Storage display thresholds do not yet follow TrueNAS-wide or
per-disk temperature overrides. Silencing an alert is not disk recovery.
signals. Storage temperature colouring and heat reasons follow the saved
per-disk override, then the TrueNAS-wide default, then the inherited by-type
policy. Off leaves the temperature visible but unjudged; last known readings
are not current heat. Silencing an alert is not disk recovery.
### Metric thresholds, Off and inheritance
+5 -4
View File
@@ -94,10 +94,11 @@ Temperature rule disables that rule, not its native SMART faults. Acknowledging
or dismissing a TrueNAS SMART alert does not repair the disk; its risk remains
while TrueNAS still reports the condition.
**Storage display limitation:** temperature colouring and its heat reason
currently use the inherited agent/by-type policy, not the TrueNAS-wide or
per-disk temperature override. A display mismatch is not proof that your saved
rule failed. Compare the same disk, observation time and saved threshold with
**Storage temperature judgement:** temperature colouring and its heat reason
use the saved per-disk temperature override, then the TrueNAS-wide default,
then the inherited agent/by-type policy. An Off or disabled temperature policy
leaves the reading visible but unjudged; a last known reading is never current
heat. A display mismatch is not proof that your saved rule failed. Compare the same disk, observation time and saved threshold with
its existing TrueNAS reading and SMART state; do not raise thresholds, run new
SMART tests, force a probe or restart merely to make the views agree.
@@ -2069,11 +2069,13 @@ Disks Global Defaults cell reads `By type` while unset
default switches the policy off, and switching it on then stages an explicit
TrueNAS-wide 55 because unset would stay off. Each disk row inherits its type's
trigger from the unsaved editor state (`resolveTrueNASDiskTemperatureDefault`).
The TrueNAS disk drawer tones a current reading from the same per-type trigger
(`getDiskTemperatureThresholds` passed into `buildTrueNASDetailSections`), so it
no longer judges heat from a fixed 55C. Judges that still differ: the TrueNAS
storage table and drawer do not apply a TrueNAS-wide value or per-disk
override.
The TrueNAS storage table and disk drawer now judge current disk heat through
`getTrueNASDiskTemperatureThresholds(disk)`: the disk's canonical-ID temperature
override, then a TrueNAS-wide value, then the per-type policy. Disabled disks
or TrueNAS Disks defaults and explicit Off values leave readings unjudged;
retained values never count as current heat. The API-only disk does not inherit
a host Disk Temp override or the agent alerts switch. Patrol/AI use the disk-specific backend resolver described below; the
frontend and backend retain their own verification boundaries.
`TestTrueNASDiskTemperatureAlertsFollowDiskTemperaturePolicy` and
`TestTrueNASDiskTemperatureAlertIgnoresRetainedReading` in
`internal/alerts/unified_eval_test.go` pin the tiers and retained readings;
@@ -4497,5 +4499,22 @@ Off/disabled, untyped, clone and nil-manager compatibility.
`TestAlertThresholdAdapter_TrueNASDiskTemperatureFollowsItsAlertTiers` and
`TestPatrolJudgesTrueNASDisksByTheirAlertTiers` cover adapter, Patrol triage,
verification and Assistant context while preserving agent-host overrides.
Frontend table/drawer tiers remain a separate Web-owned change; this backend
repair does not establish browser or native appliance acceptance.
Frontend table/drawer tiers use the Web resolver described below; these
backend tests do not establish browser or native appliance acceptance.
### TrueNAS disk display reconciliation (8 October 2026)
The TrueNAS storage table and inline resource drawer resolve temperature by
the disk resource's own `temperature` override, then `truenasDiskDefaults`,
then the per-type disk policy. Explicit zero/negative thresholds or a disabled
disk/TrueNAS default leave readings visible but unjudged. Host `diskTemperature`
overrides and the agent alerts switch do not control API-only TrueNAS disks.
The shared agent disk resolver and Proxmox `pulse-relaxed` guest resolution
remain unchanged. Existing Off-save normalization and inherited TrueNAS
threshold editors on main are retained, not replaced by the older PR stack.
Verification: `src/utils/__tests__/metricThresholds.test.ts`,
`src/features/truenas/__tests__/TrueNASStorageTopologyTable.test.tsx` and the
production table/drawer fixture `browser-tests/truenas-disk-thresholds.cjs`.
The containing backend also resolves the disk-specific tiers for Patrol.
Frontend checks do not establish native appliance or notification recovery.
@@ -9025,3 +9025,17 @@ passive steps, evidence distinctions, safety link/target, disclosure and exact
shipped mirror. Exact-parent controls reject the old guide. This is safe help
maintenance, not a collector/classifier repair, browser acceptance, native
backup/recovery proof or published availability.
### TrueNAS heat uses the configured disk policy (8 October 2026)
The production TrueNAS storage table and inline resource drawer pass each
disk's canonical resource ID through the alerts store. Type defaults, a
TrueNAS-wide value and the disk override produce one consistent status/reason
and temperature tone; retained values stay muted and unjudged. The existing
keyboard disclosure and phone table layouts are preserved.
Verification: `TrueNASStorageTopologyTable.test.tsx`, the drawer model tests,
and `browser-tests/truenas-disk-thresholds.cjs` exercise desktop Chromium and
phone WebKit, config refresh, Attention filtering and keyboard disclosure.
The fixture imports the production store/table/drawer/CSS but uses synthetic
HTTP and readings; it is not full-shell, appliance or published acceptance.
@@ -7550,5 +7550,19 @@ classification; ordinary agent disks still follow their reporting host's policy.
Registry identity, source precedence and retained-reading semantics are unchanged.
`TestIsTrueNASDiskResourceMatchesTheUnifiedEvaluator` and
`TestPatrolJudgesTrueNASDisksByTheirAlertTiers` exercise the unified registry
projection and its consumer boundaries. Table/drawer frontend acceptance remains
with Web, not established by these backend tests.
projection and its consumer boundaries. Table/drawer frontend acceptance is described below and remains
separate from these backend tests.
### TrueNAS disk-specific heat presentation (8 October 2026)
`TrueNASDiskTemperatureThresholdResolver` receives the disk resource (including
its canonical ID), not just a disk type. The table's status dot, reason,
Attention filter/count and drawer temperature row use the same resolved tiers.
A retained temperature is not current heat under any override. Changing alert
configuration updates those judgments without changing inventory identity.
Verification: `truenasPageModel.test.ts`,
`resourceDetailDrawerTrueNASModel.coverage2.test.ts` and
`browser-tests/truenas-disk-thresholds.cjs` cover disk overrides, TrueNAS-wide
limits, Off and last-known readings. Synthetic browser observations do not
establish sustained native metrics, History, revocation or release availability.
@@ -0,0 +1,216 @@
// Fresh final-byte production table/drawer proof, with synthetic HTTP only.
process.env.RAYON_NUM_THREADS = '2';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/truenas-threshold-browser';
const origin = 'http://127.0.0.1:5327';
const config = (patch = {}) => ({
enabled: true,
activationState: 'active',
guestDefaults: { memory: { trigger: 85, clear: 80 } },
nodeDefaults: {},
storageDefault: { trigger: 85, clear: 80 },
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: {
nvme: { trigger: 70, clear: 65 },
sas: { trigger: 65, clear: 60 },
sata: { trigger: 55, clear: 50 },
},
truenasDiskTemperatureByType: true,
overrides: {},
...patch,
});
(async () => {
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const version = require('playwright/package.json').version;
assert.equal(
version,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
cacheDir: path.join(output, 'vite-cache'),
optimizeDeps: { entries: ['browser-tests/truenas-disk-thresholds.html'] },
server: { host: '127.0.0.1', port: 5327, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, height] of [
['chromium-desktop', chromium, 1440, 1000],
['webkit-phone', webkit, 390, 844],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({ viewport: { width, height } });
page.setDefaultTimeout(12000);
const errors = [];
const consoleErrors = [];
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text());
});
console.log('BROWSER_VIEWPORT', name);
page.on('pageerror', (e) => errors.push(e.message));
await page.routeWebSocket(/\/ws(?:\?|$)/, (ws) =>
ws.send(
JSON.stringify({
type: 'initialState',
data: { connectedInfrastructure: [], activeAlerts: [], metrics: [], stats: {} },
}),
),
);
let current = config();
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== origin) return route.abort();
if (url.pathname === '/api/alerts/config') return route.fulfill({ json: current });
if (url.pathname.startsWith('/api/'))
return route.fulfill({
json: { data: [], resources: [], policy: {}, enabled: false, hasAuthentication: true },
});
return route.continue();
});
// Readiness is distinct from interaction assertions: allow cold fixture compilation
// within the unchanged executor deadline, then wait for the production mount.
await page.goto(`${origin}/browser-tests/truenas-disk-thresholds.html`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForFunction(() => !!window.__thresholdFixture, null, { timeout: 45000 });
if (width < 768) await page.getByRole('button', { name: /^Filters/ }).click();
await page
.getByRole('group', { name: 'Storage type', exact: true })
.getByRole('button', { name: /(?:Physical disks|Disks), 3/ })
.click();
const row = (id) => page.locator(`[data-truenas-storage-resource="${id}"]`);
const health = async (id) => {
await row(id).waitFor();
return row(id).evaluate(
(element) =>
element
.querySelector('[data-truenas-storage-health]')
?.getAttribute('data-truenas-storage-health') ?? 'healthy',
);
};
const refresh = async (value) => {
current = value;
await page.evaluate(() => window.__thresholdFixture.refresh());
};
const capture = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
observations.push({
name,
state,
viewport: { width, height },
file,
health: [
await health('nvme-wide'),
await health('nvme-raised'),
await health('nvme-retained'),
],
});
};
await refresh(config());
assert.equal(await health('nvme-wide'), 'healthy');
assert.equal(await health('nvme-raised'), 'attention');
assert.equal(await health('nvme-retained'), 'healthy');
assert.match(await row('nvme-retained').innerText(), /95/);
await capture('by-type');
await refresh(
config({
truenasDiskDefaults: { temperature: { trigger: 62, clear: 57 } },
overrides: { 'nvme-raised': { temperature: { trigger: 75, clear: 70 } } },
}),
);
assert.equal(await health('nvme-wide'), 'attention');
assert.equal(await health('nvme-raised'), 'healthy');
assert.equal(await health('nvme-retained'), 'healthy');
await page
.getByRole('group', { name: 'Status', exact: true })
.getByRole('button', { name: /Attention/ })
.click();
assert.equal(await page.locator('[data-truenas-storage-resource]').count(), 1);
assert.equal(await row('nvme-wide').count(), 1);
await page
.getByRole('group', { name: 'Status', exact: true })
.getByRole('button', { name: 'All, 3', exact: true })
.click();
const toggle = page.getByRole('button', { name: /Expand details for nvme-raised/i });
await toggle.focus();
await toggle.press('Enter');
await page.getByTestId('resource-platform-details').locator('summary').click();
await page.getByRole('button', { name: 'Show TrueNAS', exact: true }).click();
const temperatureRow = page
.getByTestId('resource-truenas-details-section')
.getByRole('row')
.filter({ has: page.getByText('Temperature', { exact: true }) });
assert.match(await temperatureRow.innerText(), /72(?:\.0)?°C/);
assert.match(
await temperatureRow.locator('td').last().getAttribute('class'),
/text-base-content/,
);
assert.doesNotMatch(
await temperatureRow.locator('td').last().getAttribute('class'),
/text-amber/,
);
assert.match(await page.locator('body').innerText(), /72(?:\.0)?°C/);
await capture('raised-drawer');
await page.getByRole('button', { name: /Collapse details for nvme-raised/i }).press('Enter');
await refresh(
config({
truenasDiskDefaults: { temperature: { trigger: 0, clear: 0 } },
overrides: { 'nvme-raised': { temperature: { trigger: 71, clear: 66 } } },
}),
);
assert.equal(await health('nvme-wide'), 'healthy');
assert.equal(await health('nvme-raised'), 'attention');
await refresh(
config({
truenasDiskDefaults: { disabled: true },
overrides: { 'nvme-raised': { temperature: { trigger: 71, clear: 66 } } },
}),
);
assert.equal(await health('nvme-raised'), 'healthy');
await capture('off');
const guest = await page.evaluate(() => window.__thresholdFixture.guestControl());
assert.deepEqual(guest, { warning: 80, critical: 92 });
assert.deepEqual(errors, []);
assert.deepEqual(consoleErrors, []);
await browser.close();
browser = null;
}
fs.writeFileSync(
path.join(output, 'observations.json'),
JSON.stringify({ result: 'passed', playwright: version, observations }, null, 2),
);
console.log(
JSON.stringify({
result: 'passed',
playwright: version,
captures: observations.length,
observations,
}),
);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TrueNAS disk threshold verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./truenas-disk-thresholds.tsx"></script>
</body>
</html>
@@ -0,0 +1,88 @@
// Production table, inline drawer and alert store. Synthetic API/config only;
// no appliance, guest-agent, alert delivery or installed acceptance.
import { onMount } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { TrueNASStorageTopologyTable } from '../src/features/truenas/TrueNASStorageTopologyTable';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { getGlobalWebSocketStore } from '../src/stores/websocket-global';
import { useAlertsActivation } from '../src/stores/alertsActivation';
import { resolveMetricDisplayThresholds } from '../src/utils/metricThresholds';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const disk = (id: string, temperature: number, retained = false): Resource =>
({
id,
type: 'physical_disk',
name: id,
displayName: id,
status: 'online',
platformType: 'truenas',
platformScopes: ['truenas'],
sourceType: 'api',
physicalDisk: {
devPath: `/dev/${id}`,
serial: `fixture-${id}`,
diskType: 'nvme',
temperature,
health: 'PASSED',
...(retained
? { collection: { temperature: { state: 'unavailable', reason: 'Fixture standby' } } }
: {}),
},
}) as Resource;
function Fixture() {
const store = useAlertsActivation();
onMount(() => {
(window as any).__thresholdFixture = {
refresh: store.refreshConfig,
thresholds: (id: string) => store.getTrueNASDiskTemperatureThresholds(disk(id, 72)),
guestControl: () =>
resolveMetricDisplayThresholds(store.config(), 'guest', 'memory', 'guest', [
'pulse-relaxed',
]),
};
});
const resources = [
disk('nvme-wide', 64),
disk('nvme-raised', 72),
disk('nvme-retained', 95, true),
];
return (
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">TrueNAS disk temperature</h1>
<p class="text-xs text-muted">
Synthetic readings and configuration; no appliance operations.
</p>
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
/>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<WebSocketContext.Provider value={getGlobalWebSocketStore()}>
<Fixture />
</WebSocketContext.Provider>
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,47 @@
{
"version": 1,
"base_sha": "c9d17b81b18de199157afe3a541a17a8352be995",
"verified_at": "2026-10-09T13:06:31Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/truenasPageModel.ts",
"frontend-modern/src/stores/alertsActivation.ts",
"frontend-modern/src/utils/metricThresholds.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "031fcb046ec519e3877323c48051cc1055c0ab684e06710a6510ff37df5b56ac",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "94e4b364c744f204ff52e2d28cde6b80a5a66ac414c7ba702113e960a6a67e17",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "62f064405b0c8e947ef1127874d3d06f6e3330153594de11b3ff3b443d29444c",
"frontend-modern/src/features/truenas/truenasPageModel.ts": "e8dd896694626e81de97343f79cd988311861a678eca1d3925883e8a61644f55",
"frontend-modern/src/stores/alertsActivation.ts": "5e080281342faa3753c3d3216d0492d365a10475e498f8a71d2344ec979675bf",
"frontend-modern/src/utils/metricThresholds.ts": "6024a0ca020172734c8ce30c8e054714174bd67a0aa66ff1a370c28079c7f8a4"
},
"routes": ["/browser-tests/truenas-disk-thresholds.html"],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 390,
"height": 844
}
],
"states": [
"Production TrueNAS table, inline ResourceDetailDrawer, alert store, router context and CSS with synthetic HTTP/config/readings; not full-shell, appliance, History, notification or release acceptance. Playwright 1.56.1 equals the committed integration lock; desktop Chromium and phone WebKit.",
"By-type NVMe trigger 70C: current 72C disk Attention, 64C disk healthy. Retained 95C reading visible, dotted/muted and unjudged.",
"TrueNAS-wide 62C makes the 64C disk Attention. A per-disk canonical-ID override 75C keeps the separate 72C disk healthy and its expanded drawer Temperature tone normal.",
"TrueNAS-wide zero leaves the disk with no override unjudged; explicit per-disk 71C still judges its 72C reading. Disabled TrueNAS Disks defaults mute every disk even with that override.",
"The shared pulse-relaxed guest control remains warning 80 / critical 92 for memory. No uncaught page errors or error console messages on the complete final pass. Six final screenshots inspected; desktop/phone controls and drawer content legible.",
"Fresh pass over c9d17b81 composition: the page model preserves current main Recovery-field removal and disk-specific policy. All six new captures inspected; browser command and outer execution exit 0. Original retained browser/history limitations remain historical, not reconstructed."
],
"interactions": [
"Refresh configuration through the production alert store and check status dots, reasons and counts.",
"Filter Attention, verify only the 64C disk remains, then restore Status All without changing the disk kind filter.",
"Open the production inline drawer by keyboard Enter, expand Platform details and Show TrueNAS, verify the 72C temperature is normal, and collapse by keyboard.",
"Expand phone Filters and repeat the same disk/status/disclosure interactions in WebKit at 390x844."
]
}
@@ -0,0 +1,48 @@
{
"version": 1,
"base_sha": "55f80f7adab5f6286cef5ad7f3eecd7f67266fb2",
"verified_at": "2026-10-08T10:16:10Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/truenasPageModel.ts",
"frontend-modern/src/stores/alertsActivation.ts",
"frontend-modern/src/utils/metricThresholds.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "031fcb046ec519e3877323c48051cc1055c0ab684e06710a6510ff37df5b56ac",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "94e4b364c744f204ff52e2d28cde6b80a5a66ac414c7ba702113e960a6a67e17",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "62f064405b0c8e947ef1127874d3d06f6e3330153594de11b3ff3b443d29444c",
"frontend-modern/src/features/truenas/truenasPageModel.ts": "a6def9d4c3e5da5c61b863780ea206acfc663345a914f1c8d901b426ad5597b2",
"frontend-modern/src/stores/alertsActivation.ts": "5e080281342faa3753c3d3216d0492d365a10475e498f8a71d2344ec979675bf",
"frontend-modern/src/utils/metricThresholds.ts": "6024a0ca020172734c8ce30c8e054714174bd67a0aa66ff1a370c28079c7f8a4"
},
"routes": [
"/browser-tests/truenas-disk-thresholds.html"
],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 390,
"height": 844
}
],
"states": [
"Production TrueNAS table, inline ResourceDetailDrawer, alert store, router context and CSS with synthetic HTTP/config/readings; not full-shell, appliance, History, notification or release acceptance. Playwright 1.56.1 equals the committed integration lock; desktop Chromium and phone WebKit.",
"By-type NVMe trigger 70C: current 72C disk Attention, 64C disk healthy. Retained 95C reading visible, dotted/muted and unjudged.",
"TrueNAS-wide 62C makes the 64C disk Attention. A per-disk canonical-ID override 75C keeps the separate 72C disk healthy and its expanded drawer Temperature tone normal.",
"TrueNAS-wide zero leaves the disk with no override unjudged; explicit per-disk 71C still judges its 72C reading. Disabled TrueNAS Disks defaults mute every disk even with that override.",
"The shared pulse-relaxed guest control remains warning 80 / critical 92 for memory. No uncaught page errors or error console messages on the complete final pass. Six final screenshots inspected; desktop/phone controls and drawer content legible."
],
"interactions": [
"Refresh configuration through the production alert store and check status dots, reasons and counts.",
"Filter Attention, verify only the 64C disk remains, then restore Status All without changing the disk kind filter.",
"Open the production inline drawer by keyboard Enter, expand Platform details and Show TrueNAS, verify the 72C temperature is normal, and collapse by keyboard.",
"Expand phone Filters and repeat the same disk/status/disclosure interactions in WebKit at 390x844."
]
}
+4 -2
View File
@@ -567,8 +567,10 @@ check that the intended value persisted.
For TrueNAS physical disks, read the [disk temperature and health guide](TRUENAS.md#disk-temperature-and-health)
before changing a rule: heat, SMART faults and missing readings are different
signals, and Storage display thresholds do not yet follow TrueNAS-wide or
per-disk temperature overrides. Silencing an alert is not disk recovery.
signals. Storage temperature colouring and heat reasons follow the saved
per-disk override, then the TrueNAS-wide default, then the inherited by-type
policy. Off leaves the temperature visible but unjudged; last known readings
are not current heat. Silencing an alert is not disk recovery.
### Metric thresholds, Off and inheritance
+5 -4
View File
@@ -94,10 +94,11 @@ Temperature rule disables that rule, not its native SMART faults. Acknowledging
or dismissing a TrueNAS SMART alert does not repair the disk; its risk remains
while TrueNAS still reports the condition.
**Storage display limitation:** temperature colouring and its heat reason
currently use the inherited agent/by-type policy, not the TrueNAS-wide or
per-disk temperature override. A display mismatch is not proof that your saved
rule failed. Compare the same disk, observation time and saved threshold with
**Storage temperature judgement:** temperature colouring and its heat reason
use the saved per-disk temperature override, then the TrueNAS-wide default,
then the inherited agent/by-type policy. An Off or disabled temperature policy
leaves the reading visible but unjudged; a last known reading is never current
heat. A display mismatch is not proof that your saved rule failed. Compare the same disk, observation time and saved threshold with
its existing TrueNAS reading and SMART state; do not raise thresholds, run new
SMART tests, force a probe or restart merely to make the views agree.
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { AlertConfig } from '@/types/alerts';
import {
buildTrueNASDetailSections,
buildTrueNASDetailsSummary,
@@ -14,6 +15,7 @@ import type {
ResourceTrueNASShareMeta,
ResourceTrueNASVMMeta,
} from '@/types/resource';
import { resolveTrueNASDiskTemperatureDisplayThresholds } from '@/utils/metricThresholds';
// Every target helper (except the exported buildTrueNASDetailsSummary) is
// module-private, so each case drives it through the two public entry points
@@ -833,8 +835,8 @@ describe('buildTrueNASDiskSections additional branches', () => {
// The drawer judges heat by the disk type's alert trigger, through the
// resolver the alerts store provides; these are the factory triggers.
const factoryTriggers: Record<string, number> = { nvme: 70, sas: 65, sata: 55 };
const resolveThresholds = (diskType: string) => {
const critical = factoryTriggers[diskType.toLowerCase()] ?? 55;
const resolveThresholds = (disk: Pick<Resource, 'id' | 'physicalDisk'>) => {
const critical = factoryTriggers[(disk.physicalDisk?.diskType ?? '').toLowerCase()] ?? 55;
return { warning: critical - 5, critical };
};
const temperatureTone = (disk: Partial<ResourcePhysicalDiskMeta>) =>
@@ -854,6 +856,52 @@ describe('buildTrueNASDiskSections additional branches', () => {
expect(temperatureTone({ diskType: 'sata', temperature: 40 })).toBe('default');
});
it('judges heat by the override and TrueNAS-wide value the disk alert uses', () => {
const config = {
enabled: true,
guestDefaults: {},
nodeDefaults: {},
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: { nvme: { trigger: 70, clear: 65 } },
storageDefault: { trigger: 85, clear: 80 },
overrides: { 'truenas-resource': { temperature: { trigger: 75, clear: 70 } } },
} as AlertConfig;
const toneUnder = (cfg: AlertConfig, disk: Partial<ResourcePhysicalDiskMeta>) =>
buildTrueNASDetailSections(diskRes(disk), (resource) =>
resolveTrueNASDiskTemperatureDisplayThresholds(
cfg,
resource.physicalDisk?.diskType,
resource.id,
),
)
.flatMap((section) => section.rows)
.find((row) => row.label === 'Temperature')?.tone;
// The drawer's disk carries a 75C override, so 72C is not heat even
// though it is past the 70C NVMe trigger.
expect(toneUnder(config, { diskType: 'nvme', temperature: 72 })).toBe('default');
expect(toneUnder(config, { diskType: 'nvme', temperature: 75 })).toBe('warning');
// Another disk's override does not reach it.
const otherDisk = {
...config,
overrides: { 'other-disk': { temperature: { trigger: 75, clear: 70 } } },
} as AlertConfig;
expect(toneUnder(otherDisk, { diskType: 'nvme', temperature: 72 })).toBe('warning');
// A TrueNAS-wide value replaces the type trigger.
const trueNASWide = {
...otherDisk,
truenasDiskDefaults: { temperature: { trigger: 62, clear: 57 } },
} as AlertConfig;
expect(toneUnder(trueNASWide, { diskType: 'nvme', temperature: 64 })).toBe('warning');
expect(toneUnder(trueNASWide, { diskType: 'nvme', temperature: 60 })).toBe('default');
// A muted disk raises no temperature alert, so it is never heat.
const muted = {
...trueNASWide,
overrides: { 'truenas-resource': { disabled: true } },
} as AlertConfig;
expect(toneUnder(muted, { diskType: 'nvme', temperature: 90 })).toBe('default');
});
it('judges no heat without thresholds', () => {
expect(findRow(diskRes({ diskType: 'sata', temperature: 80 }), 'Temperature')?.tone).toBe(
'default',
@@ -404,10 +404,12 @@ const diskTemperature = (disk: ResourcePhysicalDiskMeta) =>
// A retained reading, such as a silent host agent's on a merged disk row, is
// not current: it takes no heat tone and its title says why. A current reading
// is hot from its disk type's alert trigger, the threshold the TrueNAS storage
// table and the disk's temperature alert judge it by. Without a resolver, heat
// is not judged.
// is hot from the trigger the disk's temperature alert fires at (its own
// override, the TrueNAS-wide value, then its type's policy), the threshold the
// TrueNAS storage table judges it by too. Without a resolver, heat is not
// judged.
const diskTemperatureRow = (
resource: Resource,
disk: ResourcePhysicalDiskMeta,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
): ResourceDetailDrawerTrueNASRow | null => {
@@ -419,7 +421,7 @@ const diskTemperatureRow = (
tone: 'muted',
});
}
const thresholds = resolveDiskTemperatureThresholds?.(disk.diskType ?? '') ?? null;
const thresholds = resolveDiskTemperatureThresholds?.(resource) ?? null;
const hot = isPhysicalDiskRunningHot(
{ temperature: disk.temperature ?? 0, collection: disk.collection },
thresholds,
@@ -451,7 +453,7 @@ const buildTrueNASDiskSections = (
const healthRows = compactRows([
row('Health', normalizeDelimitedLabel(disk.health), { tone: diskStateTone(disk) }),
diskTemperatureRow(disk, resolveDiskTemperatureThresholds),
diskTemperatureRow(resource, disk, resolveDiskTemperatureThresholds),
row(
'Wearout',
disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout),
@@ -382,9 +382,9 @@ export const useResourceDetailDrawerDerivedState = (
const vmwareDetailsSummary = createMemo(() =>
buildVMwareDetailsSummary(resource().type, vmwareData()),
);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const { getTrueNASDiskTemperatureThresholds } = useAlertsActivation();
const trueNASDetailSections = createMemo(() =>
buildTrueNASDetailSections(resource(), getDiskTemperatureThresholds),
buildTrueNASDetailSections(resource(), getTrueNASDiskTemperatureThresholds),
);
const hasTrueNASDetails = createMemo(() => trueNASDetailSections().length > 0);
const trueNASDetailsSummary = createMemo(() => buildTrueNASDetailsSummary(resource()));
@@ -355,9 +355,9 @@ export const getTrueNASStorageTopologyIndentClass = (depth: number): string => {
const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: JSX.Element }> = (
props,
) => {
const { getDiskTemperatureThresholds } = useAlertsActivation();
const { getTrueNASDiskTemperatureThresholds } = useAlertsActivation();
const displayStatus = () =>
getTrueNASStorageDotStatus(props.row.resource, getDiskTemperatureThresholds);
getTrueNASStorageDotStatus(props.row.resource, getTrueNASDiskTemperatureThresholds);
const indicator = () => getSimpleStatusIndicator(displayStatus());
const name = () => resourceName(props.row.resource);
return (
@@ -391,10 +391,11 @@ export const TrueNASStorageTopologyTable: Component<{
kindFilter?: TrueNASStorageKindFilter;
onKindFilterChange?: (value: TrueNASStorageKindFilter) => void;
}> = (props) => {
// Disk heat follows the user's alert disk temperature thresholds.
const { getDiskTemperatureThresholds } = useAlertsActivation();
// Disk heat follows the thresholds each disk's temperature alert fires at:
// its own override, the TrueNAS-wide value, then its type's policy.
const { getTrueNASDiskTemperatureThresholds } = useAlertsActivation();
const rows = createMemo(() =>
buildTrueNASStorageTopologyRows(props.resources, getDiskTemperatureThresholds),
buildTrueNASStorageTopologyRows(props.resources, getTrueNASDiskTemperatureThresholds),
);
const [internalKindFilter, setInternalKindFilter] = createSignal<TrueNASStorageKindFilter>('all');
const kindFilter = () => props.kindFilter ?? internalKindFilter();
@@ -410,7 +411,7 @@ export const TrueNASStorageTopologyTable: Component<{
resources: scopedRows,
initialStatus: 'all' as TrueNASStorageStatusFilter,
filter: (rows, search, status) =>
filterTrueNASStorageTopologyRows(rows, search, status, getDiskTemperatureThresholds),
filterTrueNASStorageTopologyRows(rows, search, status, getTrueNASDiskTemperatureThresholds),
});
const kindFilters = createMemo<FilterDef[]>(() => [
{
@@ -427,7 +428,7 @@ export const TrueNASStorageTopologyTable: Component<{
filterTrueNASStorageTopologyRowsByKind(rows(), option.value),
tableState.search(),
tableState.status(),
getDiskTemperatureThresholds,
getTrueNASDiskTemperatureThresholds,
).length,
})),
value: kindFilter,
@@ -448,7 +449,11 @@ export const TrueNASStorageTopologyTable: Component<{
descendingFirst: ['usage', 'disks', 'temp'],
});
const sortedRows = createMemo(() =>
sortTrueNASStorageTopologyRows(tableState.filtered(), sort, getDiskTemperatureThresholds),
sortTrueNASStorageTopologyRows(
tableState.filtered(),
sort,
getTrueNASDiskTemperatureThresholds,
),
);
return (
@@ -557,7 +562,7 @@ export const TrueNASStorageTopologyTable: Component<{
{(row) => {
const resource = () => row.resource;
const issue = createMemo(() =>
getTrueNASStorageIssue(resource(), getDiskTemperatureThresholds),
getTrueNASStorageIssue(resource(), getTrueNASDiskTemperatureThresholds),
);
const detailRowId = () => drawer.detailRowId(resource());
const isExpanded = () => drawer.isExpanded(resource());
@@ -381,4 +381,66 @@ describe('TrueNASStorageTopologyTable', () => {
eventBus.emit('org_switched', 'default');
}
});
it("follows a disk's own override and the TrueNAS-wide value its alert uses", async () => {
const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({
enabled: true,
activationState: 'active',
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: {
nvme: { trigger: 70, clear: 65 },
sas: { trigger: 65, clear: 60 },
sata: { trigger: 55, clear: 50 },
},
truenasDiskDefaults: { temperature: { trigger: 62, clear: 57 } },
truenasDiskTemperatureByType: true,
overrides: { nvme1: { temperature: { trigger: 75, clear: 70 } } },
} as unknown as AlertConfig);
try {
const nvmeDisk = (id: string, temperature: number) =>
makeStorageResource({
id,
type: 'physical_disk',
name: id,
storage: undefined,
physicalDisk: {
devPath: `/dev/${id}`,
serial: `serial-${id}`,
diskType: 'nvme',
temperature,
},
});
const resources = [nvmeDisk('nvme0', 64), nvmeDisk('nvme1', 72)];
const { container } = render(() => (
<TrueNASStorageTopologyTable
resources={resources}
scope={resources}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
/>
));
const health = (id: string) => {
const row = container.querySelector(`[data-truenas-storage-resource="${id}"]`);
if (!row) return 'missing';
return (
row
.querySelector('[data-truenas-storage-health]')
?.getAttribute('data-truenas-storage-health') ?? 'healthy'
);
};
// Before the alert configuration loads, the factory NVMe trigger judges.
expect(health('nvme0')).toBe('healthy');
expect(health('nvme1')).toBe('attention');
await useAlertsActivation().refreshConfig();
// The TrueNAS-wide 62C now judges nvme0, and nvme1's own 75C override
// beats it, so the 72C disk that will never alert is not Attention.
await waitFor(() => expect(health('nvme0')).toBe('attention'));
expect(health('nvme1')).toBe('healthy');
expect(screen.getByRole('button', { name: /Attention/ })).toHaveTextContent('1');
} finally {
getConfig.mockRestore();
eventBus.emit('org_switched', 'default');
}
});
});
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import type { RecoveryPoint } from '@/types/recovery';
import type { AlertConfig } from '@/types/alerts';
import type { Resource } from '@/types/resource';
import { resolveDiskTemperatureDisplayThresholds } from '@/utils/metricThresholds';
import { resolveTrueNASDiskTemperatureDisplayThresholds } from '@/utils/metricThresholds';
import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
@@ -20,6 +21,7 @@ import {
filterTrueNASVMs,
getTrueNASPageTabSpecs,
getTrueNASResourceDisplayStatus,
getTrueNASStorageDotStatus,
getTrueNASStorageIssue,
mapTrueNASAppStatus,
mapTrueNASIncidentSeverity,
@@ -634,8 +636,8 @@ describe('truenasPageModel', () => {
it('flags a disk whose current reading reaches its alert trigger', () => {
// Disk risk carries no heat, so a hot disk stays `online`; the page judges
// it by the alert disk temperature thresholds, as Physical Disks does.
const resolveFactory = (diskType: string) =>
resolveDiskTemperatureDisplayThresholds(null, diskType);
const resolveFactory = (disk: Pick<Resource, 'id' | 'physicalDisk'>) =>
resolveTrueNASDiskTemperatureDisplayThresholds(null, disk.physicalDisk?.diskType, disk.id);
const hotDisk = makeResource({
id: 'disk-hot',
type: 'physical_disk',
@@ -707,6 +709,65 @@ describe('truenasPageModel', () => {
expect(mapTrueNASStorageStatus(retained, resolveFactory)).toBe('healthy');
});
it('judges disk heat by the override and TrueNAS-wide value its alert uses', () => {
const config = {
enabled: true,
guestDefaults: {},
nodeDefaults: {},
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: { nvme: { trigger: 70, clear: 65 }, sata: { trigger: 55, clear: 50 } },
storageDefault: { trigger: 85, clear: 80 },
overrides: { 'disk-raised': { temperature: { trigger: 75, clear: 70 } } },
} as AlertConfig;
const resolveWith = (cfg: AlertConfig) => (disk: Pick<Resource, 'id' | 'physicalDisk'>) =>
resolveTrueNASDiskTemperatureDisplayThresholds(cfg, disk.physicalDisk?.diskType, disk.id);
const nvmeDisk = (id: string, temperature: number) =>
makeResource({
id,
type: 'physical_disk',
name: id,
status: 'online',
physicalDisk: {
devPath: `/dev/${id}`,
serial: `serial-${id}`,
diskType: 'nvme',
health: 'UNKNOWN',
temperature,
},
});
// At 72C an NVMe is past its 70C type trigger, but the disk whose own
// override raised its alert to 75C will not alert, so it is not heat.
const plain = nvmeDisk('disk-plain', 72);
const raised = nvmeDisk('disk-raised', 72);
expect(mapTrueNASStorageStatus(plain, resolveWith(config))).toBe('attention');
expect(mapTrueNASStorageStatus(raised, resolveWith(config))).toBe('healthy');
expect(getTrueNASStorageIssue(raised, resolveWith(config))).toBeNull();
expect(
getTrueNASStorageIssue(nvmeDisk('disk-raised', 76), resolveWith(config))?.reasons,
).toEqual(['Disk temperature is 76°C, at or above its 75°C alert threshold.']);
// A TrueNAS-wide 62C applies to every disk without its own override.
const trueNASWide = {
...config,
truenasDiskDefaults: { temperature: { trigger: 62, clear: 57 } },
} as AlertConfig;
const warm = nvmeDisk('disk-warm', 64);
expect(mapTrueNASStorageStatus(warm, resolveWith(config))).toBe('healthy');
expect(getTrueNASStorageIssue(warm, resolveWith(trueNASWide))?.reasons).toEqual([
'Disk temperature is 64°C, at or above its 62°C alert threshold.',
]);
expect(mapTrueNASStorageStatus(raised, resolveWith(trueNASWide))).toBe('healthy');
// Switched off TrueNAS-wide, no disk without an override is heat.
const trueNASOff = {
...config,
truenasDiskDefaults: { temperature: { trigger: 0, clear: 0 } },
} as AlertConfig;
expect(mapTrueNASStorageStatus(plain, resolveWith(trueNASOff))).toBe('healthy');
expect(getTrueNASStorageDotStatus(plain, resolveWith(trueNASOff))).toBe('online');
expect(getTrueNASStorageDotStatus(plain, resolveWith(config))).toBe('warning');
});
it('renders expected receive-side replication readonly posture as healthy', () => {
const replicatedDataset = makeResource({
id: 'dataset-replica',
@@ -728,16 +728,18 @@ export function mapTrueNASIncidentSeverity(
}
/**
* Resolves a disk type's alert disk temperature thresholds: the alerts store's
* `getDiskTemperatureThresholds`. Without one, disk heat is not judged.
* Resolves the temperature thresholds that judge a TrueNAS disk: the alerts
* store's `getTrueNASDiskTemperatureThresholds`, which applies the disk's own
* override, then the TrueNAS-wide value, then its type's policy, as the disk's
* temperature alert does. Without one, disk heat is not judged.
*/
export type TrueNASDiskTemperatureThresholdResolver = (
diskType: string,
disk: Pick<Resource, 'id' | 'physicalDisk'>,
) => MetricDisplayThresholds | null;
// Disk risk carries no heat, so a disk whose current reading has reached its
// type's alert trigger is judged here, by the same policy as the Physical
// Disks Running Hot verdict. Returns the heat reason, or null.
// Disk risk carries no heat, so a disk whose current reading has reached the
// trigger its temperature alert fires at is judged here. Returns the heat
// reason, or null.
function getTrueNASDiskHeatSummary(
resource: Resource,
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
@@ -745,7 +747,7 @@ function getTrueNASDiskHeatSummary(
const disk = resource.physicalDisk;
if (!resolveDiskTemperatureThresholds || resource.type !== 'physical_disk' || !disk) return null;
const temperature = disk.temperature ?? 0;
const thresholds = resolveDiskTemperatureThresholds(disk.diskType || '');
const thresholds = resolveDiskTemperatureThresholds(resource);
return isPhysicalDiskRunningHot({ temperature, collection: disk.collection }, thresholds)
? getPhysicalDiskHeatSummary(temperature, thresholds)
: null;
@@ -2,6 +2,7 @@ import { createSignal } from 'solid-js';
import { AlertsAPI } from '@/api/alerts';
import type { AlertConfig, ActivationState as ActivationStateType } from '@/types/alerts';
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import { setGlobalAlertsDetectionEnabled } from '@/utils/alertsActivation';
import { FACTORY_NODE_DEFAULTS } from '@/utils/alertThresholdDefaults';
import { logger } from '@/utils/logger';
@@ -10,6 +11,7 @@ import {
type DisplayMetricType,
resolveDiskTemperatureDisplayThresholds,
resolveMetricDisplayThresholds,
resolveTrueNASDiskTemperatureDisplayThresholds,
} from '@/utils/metricThresholds';
import { eventBus } from './events';
@@ -159,6 +161,17 @@ const getDiskTemperatureThresholds = (
return resolveDiskTemperatureDisplayThresholds(config(), diskType, resourceIds);
};
// A TrueNAS disk's temperature thresholds, resolved by the tiers its
// temperature alert uses: its own override, the TrueNAS-wide value, then the
// per-type policy.
const getTrueNASDiskTemperatureThresholds = (disk: Pick<Resource, 'id' | 'physicalDisk'>) => {
return resolveTrueNASDiskTemperatureDisplayThresholds(
config(),
disk.physicalDisk?.diskType,
disk.id,
);
};
eventBus.on('org_switched', () => {
setConfig(null);
setGlobalAlertsDetectionEnabled(null);
@@ -185,6 +198,7 @@ export const useAlertsActivation = () => ({
getTemperatureThreshold,
getMetricThresholds,
getDiskTemperatureThresholds,
getTrueNASDiskTemperatureThresholds,
// Actions
refreshConfig,
@@ -11,6 +11,7 @@ import {
hasPulseRelaxedGuestTag,
resolveDiskTemperatureDisplayThresholds,
resolveMetricDisplayThresholds,
resolveTrueNASDiskTemperatureDisplayThresholds,
METRIC_THRESHOLDS,
type MetricType,
} from '@/utils/metricThresholds';
@@ -604,6 +605,122 @@ describe('metricThresholds', () => {
).toBeNull();
});
it('judges a TrueNAS disk by the tiers its temperature alert resolves', () => {
const config = {
enabled: true,
guestDefaults: {},
nodeDefaults: {},
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: {
nvme: { trigger: 70, clear: 65 },
sata: { trigger: 55, clear: 50 },
},
storageDefault: { trigger: 85, clear: 80 },
overrides: {
'disk-raised': { temperature: { trigger: 75, clear: 70 } },
'disk-off-value': { temperature: { trigger: 0, clear: 0 } },
'disk-minus-one': { temperature: { trigger: -1, clear: 0 } },
'disk-muted': { disabled: true },
'disk-note-only': { note: 'spare shelf' },
'disk-agent-key': { diskTemperature: { trigger: 80, clear: 75 } },
},
} as AlertConfig;
const resolve = (cfg: AlertConfig, diskType: string, id: string) =>
resolveTrueNASDiskTemperatureDisplayThresholds(cfg, diskType, id);
// Without a TrueNAS-wide value each disk follows its type's policy.
expect(resolve(config, 'nvme', 'disk-plain')).toEqual({ warning: 65, critical: 70 });
expect(resolve(config, 'SATA', 'disk-plain')).toEqual({ warning: 50, critical: 55 });
// An empty or unknown type, or one with no entry, takes the Disk Temp
// default, as the backend's per-type lookup does.
expect(resolve(config, '', 'disk-plain')).toEqual({ warning: 50, critical: 55 });
expect(resolve(config, 'scsi', 'disk-plain')).toEqual({ warning: 50, critical: 55 });
expect(resolve(config, 'sas', 'disk-plain')).toEqual({ warning: 50, critical: 55 });
// The disk's own temperature override beats the type, raising or
// switching it off; an override that mutes the disk's alerts is off too.
expect(resolve(config, 'nvme', 'disk-raised')).toEqual({ warning: 70, critical: 75 });
expect(resolve(config, 'nvme', 'disk-off-value')).toBeNull();
expect(resolve(config, 'nvme', 'disk-minus-one')).toBeNull();
expect(resolve(config, 'nvme', 'disk-muted')).toBeNull();
// An override without a temperature, or with only the agent disk
// temperature metric, leaves the default tiers.
expect(resolve(config, 'nvme', 'disk-note-only')).toEqual({ warning: 65, critical: 70 });
expect(resolve(config, 'nvme', 'disk-agent-key')).toEqual({ warning: 65, critical: 70 });
expect(resolveTrueNASDiskTemperatureDisplayThresholds(config, 'nvme', null)).toEqual({
warning: 65,
critical: 70,
});
// A TrueNAS-wide value replaces the type's policy for every disk, and a
// disk override still beats it. Zero switches it off.
const trueNASWide = {
...config,
truenasDiskDefaults: { temperature: { trigger: 62, clear: 57 } },
} as AlertConfig;
expect(resolve(trueNASWide, 'nvme', 'disk-plain')).toEqual({ warning: 57, critical: 62 });
expect(resolve(trueNASWide, 'sata', 'disk-plain')).toEqual({ warning: 57, critical: 62 });
expect(resolve(trueNASWide, 'nvme', 'disk-raised')).toEqual({ warning: 70, critical: 75 });
const trueNASOff = {
...config,
truenasDiskDefaults: { temperature: { trigger: 0, clear: 0 } },
} as AlertConfig;
expect(resolve(trueNASOff, 'nvme', 'disk-plain')).toBeNull();
expect(resolve(trueNASOff, 'nvme', 'disk-raised')).toEqual({ warning: 70, critical: 75 });
// An empty TrueNAS Disks entry is unset: the type's policy applies.
const trueNASUnset = { ...config, truenasDiskDefaults: {} } as AlertConfig;
expect(resolve(trueNASUnset, 'nvme', 'disk-plain')).toEqual({ warning: 65, critical: 70 });
// TrueNAS Disks defaults that switch alerts off skip every disk's
// evaluation, its own override included.
const trueNASMuted = {
...config,
truenasDiskDefaults: { disabled: true, temperature: { trigger: 62, clear: 57 } },
} as AlertConfig;
expect(resolve(trueNASMuted, 'nvme', 'disk-plain')).toBeNull();
expect(resolve(trueNASMuted, 'nvme', 'disk-raised')).toBeNull();
});
it('reaches a TrueNAS disk only through the agent Disk Temp value', () => {
const config = {
enabled: true,
guestDefaults: {},
nodeDefaults: {},
agentDefaults: { diskTemperature: { trigger: 55, clear: 50 } },
diskTempByType: { nvme: { trigger: 70, clear: 65 } },
storageDefault: { trigger: 85, clear: 80 },
overrides: {},
} as AlertConfig;
// No host agent reports a TrueNAS disk, so the agent default's alert
// switch leaves it judged.
const agentsMuted = {
...config,
agentDefaults: { ...config.agentDefaults, disabled: true },
} as AlertConfig;
expect(resolveTrueNASDiskTemperatureDisplayThresholds(agentsMuted, 'nvme', 'd')).toEqual({
warning: 65,
critical: 70,
});
// A switched-off Disk Temp default switches the per-type tier off, but a
// TrueNAS-wide value still applies.
const diskTempOff = {
...config,
agentDefaults: { diskTemperature: { trigger: 0, clear: 0 } },
} as AlertConfig;
expect(resolveTrueNASDiskTemperatureDisplayThresholds(diskTempOff, 'nvme', 'd')).toBeNull();
expect(
resolveTrueNASDiskTemperatureDisplayThresholds(
{ ...diskTempOff, truenasDiskDefaults: { temperature: { trigger: 60, clear: 55 } } },
'nvme',
'd',
),
).toEqual({ warning: 55, critical: 60 });
// Without config, the factory per-type triggers apply.
expect(resolveTrueNASDiskTemperatureDisplayThresholds(null, 'nvme', 'd')).toEqual({
warning: 65,
critical: 70,
});
});
it('falls back to seeded per-type disk temperature defaults without config', () => {
expect(resolveDiskTemperatureDisplayThresholds(null, 'nvme')).toEqual({
warning: 65,
+60 -14
View File
@@ -350,6 +350,34 @@ export const resolveMetricDisplayThresholds = (
return resolveThreshold(value, getFallbackCritical(scope, metric), margin);
};
// The disk temperature policy for a disk type, with no host override: the
// type's diskTempByType entry, else the agent Disk Temp default. A switched-off
// Disk Temp default switches every type off.
const resolveDiskTemperatureTypePolicy = (
config: AlertConfig | null,
normalizedType: string,
margin: number,
): MetricDisplayThresholds | null => {
const baseValue = getBaseThresholdValue(config?.agentDefaults, 'diskTemperature');
if (baseValue !== undefined && resolveThreshold(baseValue, undefined, margin) === null) {
return null;
}
const byTypeFallback = normalizedType ? FACTORY_DISK_TEMP_BY_TYPE[normalizedType] : undefined;
if (normalizedType) {
const byType = config?.diskTempByType?.[normalizedType];
if (isHysteresisThreshold(byType)) {
return resolveThreshold(byType, byTypeFallback, margin);
}
}
return resolveThreshold(
baseValue,
byTypeFallback ?? FACTORY_AGENT_DEFAULTS.diskTemperature,
margin,
);
};
/**
* Resolve display thresholds for a physical disk's SMART temperature.
* Mirrors the backend precedence: an explicit diskTemperature override on the
@@ -378,24 +406,42 @@ export const resolveDiskTemperatureDisplayThresholds = (
return resolveThreshold(overrideValue, FACTORY_AGENT_DEFAULTS.diskTemperature, margin);
}
const baseValue = getBaseThresholdValue(config?.agentDefaults, 'diskTemperature');
if (baseValue !== undefined && resolveThreshold(baseValue, undefined, margin) === null) {
return resolveDiskTemperatureTypePolicy(config, normalizedType, margin);
};
/**
* Resolve display thresholds for a TrueNAS disk's temperature by the tiers its
* temperature alert resolves (backend `trueNASDiskTemperatureDefaultNoLock`
* under `effectiveAlertPolicyNoLock`): the disk's own temperature override,
* stored under its resource ID, then a TrueNAS-wide value saved under TrueNAS
* Disks, then the disk temperature policy for its type. An override or a
* TrueNAS Disks default that switches alerts off, or a value of 0 at either
* explicit tier, means off (null). No host agent reports a TrueNAS disk, so
* host overrides and the agent default's alert switch do not reach it; only
* the agent Disk Temp value does, through the per-type tier.
*/
export const resolveTrueNASDiskTemperatureDisplayThresholds = (
config: AlertConfig | null,
diskType: string | null | undefined,
resourceId: string | null | undefined,
): MetricDisplayThresholds | null => {
const margin = normalizeMargin(config?.hysteresisMargin);
const override = findOverride(config?.overrides, resourceId ?? undefined);
if (override?.disabled || config?.truenasDiskDefaults?.disabled === true) {
return null;
}
const byTypeFallback = normalizedType ? FACTORY_DISK_TEMP_BY_TYPE[normalizedType] : undefined;
if (normalizedType) {
const byType = config?.diskTempByType?.[normalizedType];
if (isHysteresisThreshold(byType)) {
return resolveThreshold(byType, byTypeFallback, margin);
}
const overrideValue = getOverrideValue(override, 'temperature');
if (overrideValue !== undefined) {
return resolveThreshold(overrideValue, undefined, margin);
}
return resolveThreshold(
baseValue,
byTypeFallback ?? FACTORY_AGENT_DEFAULTS.diskTemperature,
margin,
);
const trueNASWideValue = getBaseThresholdValue(config?.truenasDiskDefaults, 'temperature');
if (trueNASWideValue !== undefined) {
return resolveThreshold(trueNASWideValue, undefined, margin);
}
return resolveDiskTemperatureTypePolicy(config, (diskType ?? '').trim().toLowerCase(), margin);
};
const getFallbackSeverityThresholds = (metric: DisplayMetricType): MetricDisplayThresholds => {
+11 -3
View File
@@ -99,8 +99,9 @@ class TrueNASDocsTest(unittest.TestCase):
def test_disk_display_mismatch_has_a_passive_private_reporting_route(self):
prose = " ".join(self.disk_health_section().split())
for boundary in (
"**Storage display limitation:**", "inherited agent/by-type policy",
"not the TrueNAS-wide or per-disk temperature override",
"**Storage temperature judgement:**", "inherited agent/by-type policy",
"saved per-disk temperature override, then the TrueNAS-wide default",
"reading visible but unjudged", "last known reading is never current heat",
"not proof that your saved rule failed", "same disk, observation time and saved threshold",
"existing TrueNAS reading and SMART state", "do not raise thresholds",
"run new SMART tests", "force a probe or restart", "only the disk type",
@@ -109,8 +110,15 @@ class TrueNASDocsTest(unittest.TestCase):
):
with self.subTest(boundary=boundary):
self.assertIn(boundary, prose)
self.assertNotIn("Storage display limitation", prose)
config = ROOT / "docs/CONFIGURATION.md"
self.assertIn("TRUENAS.md#disk-temperature-and-health", config.read_text())
config_prose = " ".join(config.read_text().split())
self.assertIn("TRUENAS.md#disk-temperature-and-health", config_prose)
self.assertIn("per-disk override, then the TrueNAS-wide default, then the inherited by-type policy",
config_prose)
self.assertIn("Off leaves the temperature visible but unjudged", config_prose)
self.assertIn("last known readings are not current heat", config_prose)
self.assertNotIn("do not yet follow", config_prose)
self.assertEqual(config.read_bytes(),
(ROOT / "frontend-modern/public/docs/CONFIGURATION.md").read_bytes())