mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
@@ -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
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+50
-2
@@ -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),
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user