mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Make v6.5 History accessible and keep disk temperatures visible (#2404)
Some checks are pending
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Some checks are pending
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
## What changed and why Backport the reviewed History usability and request-state repairs and the disk-temperature display repair to v6.5: - Inspect History readings with Left/Right and Home/End, clear inspection with Escape, and hear the active reading. Wrapped tooltip text fits its geometry. - Empty History states no longer promise future collection. A failed background refresh warns while retaining the last successful readings and their source; recovery or a new selection clears the warning. - Disk details retain an independently reported temperature when optional extended SMART attributes are absent, without inventing counters or changing units, configured thresholds, or the unavailable-details status. These are repairs to existing surfaces, prompted by inaccessible chart inspection, silently failed refreshes, and a reproduced hidden 42°C disk reading. They do not claim that native PBS/TrueNAS collection or every reported History symptom is fixed. ## Included source - Reviewed History candidate `20261002T163600Z-release-line`: `1330fc8105`, composing main History repairs through `df18123e7d`. - Reviewed composed candidate `20261002T175724Z-release-line`: `5a79299001`, adapting disk-temperature repair `442717f409` and refreshing browser proof for the complete composed delta. - Complete unpublished range: `ea3b7a896527eddcadc07aa57dd5f80f4886a947..5a79299001fc1627c13410618f663856f7b851f9`, including additive ancestry merge `a3f3b7564e`. Reviewed commit identities are preserved; the current published v6.5 frontier is already included. ## Validation and limits - Recorded reviewed source proof `source-rdr86c0k`: 2,797 connected tests, typecheck, lint, production/embedded builds and budgets passed, with complete output and guest shutdown. Separate exact-path boundary proof passed 11 tests. The earlier complete frontend suite `source-3e4lem7w` remains evidence for the unchanged History content; sufficient proof was not repeated solely for batching. - Chromium and WebKit predecessor controls reproduced hidden temperature and the History defects. Final composed-line browser checks passed ten desktop/phone temperature observations, fourteen keyboard observations and sixteen request-state observations; screenshots were inspected in the reviewed evidence. The receipt binds all five final runtime files to the composed candidate's actual parent. - Publication provenance, range whitespace and the line's supported browser-verification guard passed. All five changed runtime blobs exactly match current public main and the reviewed v6.4 backports. - Earlier admission/selector errors remain recorded; they are not passing tests. Exact-head PR checks must pass before landing. Native panels, installed acceptance and containing release qualification remain separate. This is later-checkpoint source for v6.5, not a change to frozen v6.4.6 and not a release publication or promotion decision. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
commit
f034dc6518
24 changed files with 708 additions and 64 deletions
|
|
@ -7453,3 +7453,30 @@ field, polling, supplied-data transitions, locked/empty targets and cleanup.
|
|||
accessible description in desktop Chromium and phone WebKit, including a late
|
||||
old-target response and current-target loading/failure. Synthetic response proof
|
||||
is not native PBS collection or whole-report #1723 acceptance.
|
||||
|
||||
### Shared History keyboard inspection
|
||||
|
||||
The existing Storage pool and disk canvas charts accept keyboard focus with a
|
||||
visible focus indicator. Focus selects the latest observed sample; Left/Right
|
||||
step through actual observations, Home/End select the endpoints, and Escape or
|
||||
blur clears inspection without trapping focus. Each keyboard-selected timestamp
|
||||
and formatted value is announced politely. Pointer inspection remains available
|
||||
without announcing every mouse move. Matching sample refreshes retain inspection;
|
||||
selection changes and empty data clear stale readings. No readings are invented
|
||||
for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser
|
||||
fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
|
||||
browser proof does not establish native collector or assistive-device acceptance.
|
||||
|
||||
### Shared History request-state honesty
|
||||
|
||||
An empty successful response states only that the selected time range has no
|
||||
samples; it does not claim collection is running or promise future readings.
|
||||
A failed matching background refresh keeps the last successful data and source,
|
||||
with a visible polite status also included in the chart's accessible description.
|
||||
The warning persists while retrying and clears only on success or selection
|
||||
replacement. This applies to previously empty results too. Initial failures
|
||||
remain separate errors. No polling frequency, request or access policy changes.
|
||||
`useHistoryChartState.test.tsx` covers failure, pending recovery, empty results
|
||||
and selection changes; `browser-tests/history-status.cjs` checks actual chart
|
||||
states in desktop Chromium and phone WebKit with synthetic transport failures.
|
||||
These checks do not establish native collection or released availability.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,22 @@
|
|||
|
||||
## Purpose
|
||||
|
||||
### Independent physical disk temperature
|
||||
|
||||
Disk Overview displays a finite positive reported temperature even when the
|
||||
optional extended SMART object is missing or empty. Temperature formatting and
|
||||
configured per-disk-type thresholds remain unchanged; absent counters are not
|
||||
invented. When no detail readings or collection messages remain, the existing
|
||||
accessible unavailable-details message is shown. This presentation does not
|
||||
establish fresh collection, SMART health or native appliance acceptance.
|
||||
|
||||
Verification: `DiskDetail.test.tsx` covers same-identity snapshot replacement
|
||||
through temperature-only, hot, missing and extended-SMART states;
|
||||
`diskDetailPresentation.branchcov0724pm.test.ts` covers disk types, invalid
|
||||
readings, units and configured thresholds. The production detail fixture in
|
||||
`frontend-modern/browser-tests/disk-temperature.cjs` checks desktop and phone
|
||||
presentation with synthetic snapshots, including the predecessor's omission.
|
||||
|
||||
### Pool detail capacity observations
|
||||
|
||||
The existing pool drawer's Configuration rows preserve missing capacity as
|
||||
|
|
|
|||
70
frontend-modern/browser-tests/disk-temperature.cjs
Normal file
70
frontend-modern/browser-tests/disk-temperature.cjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const artifacts = path.join(root, 'node_modules/disk-temperature-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5225, strictPort: true } });
|
||||
let browser;
|
||||
const observations = [];
|
||||
const parent = process.argv.includes('--parent');
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
|
||||
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5225') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
return route.fulfill({json: url.pathname === '/api/license/runtime-capabilities' ? {capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: {build:'community'}, blocked_capabilities: []} : {data: [], enabled: false}});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5225/browser-tests/disk-temperature.html');
|
||||
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const fallback = page.getByText('Detailed SMART attributes are not available for this disk.', {exact:true});
|
||||
const value = text => page.getByText(text, {exact:true});
|
||||
async function state(phase, expected) {
|
||||
await value(expected).waitFor();
|
||||
const dimensions = await page.evaluate(() => ({scroll:document.documentElement.scrollWidth, width:innerWidth}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1);
|
||||
await page.screenshot({path:path.join(artifacts, `${parent?'parent-':''}${name}-${phase}.png`),fullPage:true});
|
||||
observations.push({name, version:browser.version(),phase,expected,dimensions});
|
||||
}
|
||||
if (parent) {
|
||||
await state('temperature-hidden', 'Detailed SMART attributes are not available for this disk.');
|
||||
assert.equal(await value('42°C').count(),0);
|
||||
} else {
|
||||
await state('temperature-only','42°C');
|
||||
assert.equal(await fallback.count(),0);
|
||||
assert.equal(await value('Power-On Time').count(),0);
|
||||
assert.match(await value('42°C').getAttribute('class'), /text-green-600/);
|
||||
await page.getByRole('button',{name:'Hot reading'}).click();
|
||||
await state('hot','65°C');
|
||||
assert.match(await value('65°C').getAttribute('class'), /text-red-600/);
|
||||
await page.getByRole('button',{name:'Fahrenheit',exact:true}).click();
|
||||
await state('fahrenheit','149°F');
|
||||
assert.match(await value('149°F').getAttribute('class'), /text-red-600/);
|
||||
await page.getByRole('button',{name:'No reading',exact:true}).click();
|
||||
await state('missing','Detailed SMART attributes are not available for this disk.');
|
||||
assert.equal(await value('Temperature').count(),0);
|
||||
assert.equal(await fallback.getAttribute('role'),'status');
|
||||
await page.getByRole('button',{name:'Extended SMART',exact:true}).click();
|
||||
await state('extended','42°C');
|
||||
await value('4 days').waitFor();
|
||||
await value('Reallocated Sectors').waitFor();
|
||||
assert.equal(await value('0').count(),1);
|
||||
assert.equal(await value('Temperature').count(),1);
|
||||
}
|
||||
assert.deepEqual(errors,[]);
|
||||
await browser.close(); browser=null;
|
||||
}
|
||||
fs.writeFileSync(path.join(artifacts,parent?'parent.json':'result.json'),JSON.stringify({playwright:require('playwright/package.json').version,observations},null,2));
|
||||
console.log(JSON.stringify({result:'passed',states:observations.length,parent}));
|
||||
} finally { if(browser) await browser.close(); await server.close(); }
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
1
frontend-modern/browser-tests/disk-temperature.html
Normal file
1
frontend-modern/browser-tests/disk-temperature.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Disk temperature verification</title></head><body><div id="root"></div><script type="module" src="./disk-temperature.tsx"></script></body></html>
|
||||
57
frontend-modern/browser-tests/disk-temperature.tsx
Normal file
57
frontend-modern/browser-tests/disk-temperature.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { DiskDetail } from '../src/components/Storage/DiskDetail';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import { temperatureStore } from '../src/utils/temperature';
|
||||
import '../src/index.css';
|
||||
|
||||
const Fixture = () => {
|
||||
const initial = {
|
||||
id: 'disk-fixture',
|
||||
type: 'physical_disk',
|
||||
name: 'Archive HDD',
|
||||
displayName: 'Archive HDD',
|
||||
platformType: 'proxmox-pve',
|
||||
platformId: 'fixture',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
identity: { hostname: 'storage-host' },
|
||||
physicalDisk: { devPath: '/dev/sda', model: 'Archive HDD', diskType: 'hdd', temperature: 42 },
|
||||
} as Resource;
|
||||
const [disk, setDisk] = createSignal(initial);
|
||||
const reading = (
|
||||
temperature: number,
|
||||
smart?: { powerOnHours: number; reallocatedSectors: number },
|
||||
) => {
|
||||
temperatureStore.setUnit('celsius');
|
||||
setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature, smart } });
|
||||
};
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1>Physical disk Overview</h1>
|
||||
<p>Synthetic disk snapshots; production detail component.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="min-h-11 border px-3" onClick={() => reading(65)}>
|
||||
Hot reading
|
||||
</button>
|
||||
<button class="min-h-11 border px-3" onClick={() => temperatureStore.setUnit('fahrenheit')}>
|
||||
Fahrenheit
|
||||
</button>
|
||||
<button class="min-h-11 border px-3" onClick={() => reading(0)}>
|
||||
No reading
|
||||
</button>
|
||||
<button
|
||||
class="min-h-11 border px-3"
|
||||
onClick={() => reading(42, { powerOnHours: 100, reallocatedSectors: 0 })}
|
||||
>
|
||||
Extended SMART
|
||||
</button>
|
||||
</div>
|
||||
<section class="max-w-3xl">
|
||||
<DiskDetail disk={disk()} nodes={[]} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
80
frontend-modern/browser-tests/history-keyboard.cjs
Normal file
80
frontend-modern/browser-tests/history-keyboard.cjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const artifacts = path.join(root, 'node_modules/history-keyboard-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5223, strictPort: true } });
|
||||
let browser;
|
||||
const observations = [];
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
|
||||
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { data: [], enabled: false } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5223/browser-tests/history-keyboard.html');
|
||||
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const chart = page.getByRole('img', { name: 'Usage chart', exact: true });
|
||||
await chart.waitFor();
|
||||
if (process.argv.includes('--parent')) {
|
||||
assert.equal(await chart.getAttribute('tabindex'), null);
|
||||
await page.getByRole('button', { name: 'Change target' }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.evaluate(() => document.activeElement.textContent), 'After charts');
|
||||
await page.screenshot({ path: path.join(artifacts, 'parent-skipped.png'), fullPage: true });
|
||||
console.log('Parent reproduced: Tab skips both storage charts; no keyboard inspection');
|
||||
break;
|
||||
}
|
||||
assert.equal(await chart.getAttribute('tabindex'), '0');
|
||||
await page.getByRole('button', { name: 'Change target' }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await chart.evaluate(el => el === document.activeElement), true);
|
||||
const live = page.locator('[aria-live="polite"]').first();
|
||||
for (const [phase, key, expected] of [['focus', null, '30.0%'], ['previous', 'ArrowLeft', '20.0%'], ['first', 'Home', '10.0%'], ['lower-bound', 'ArrowLeft', '10.0%'], ['last', 'End', '30.0%'], ['upper-bound', 'ArrowRight', '30.0%'], ['escape', 'Escape', '']]) {
|
||||
if (key) await page.keyboard.press(key);
|
||||
await page.waitForTimeout(60);
|
||||
assert.ok((await live.textContent()).includes(expected));
|
||||
if (!expected) assert.equal(await live.textContent(), '');
|
||||
else assert.ok((await page.locator('[data-history-chart-tooltip]').first().textContent()).includes(expected));
|
||||
if (phase === 'previous') {
|
||||
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2);
|
||||
for (const tooltip of await page.locator('[data-history-chart-tooltip]').all()) {
|
||||
assert.ok(await tooltip.evaluate(el => el.scrollHeight <= el.clientHeight));
|
||||
}
|
||||
assert.equal(await page.locator('[aria-live="polite"]').nth(1).textContent(), '');
|
||||
const outline = await chart.evaluate(el => getComputedStyle(el).outlineWidth);
|
||||
assert.notEqual(outline, '0px');
|
||||
await page.screenshot({ path: path.join(artifacts, `${name}-keyboard.png`), fullPage: true });
|
||||
}
|
||||
observations.push({ name, version: browser.version(), phase, announcement: await live.textContent() });
|
||||
}
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.getByRole('img', { name: 'Read chart', exact: true }).evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.getByRole('button', { name: 'After charts' }).evaluate(el => el === document.activeElement), true);
|
||||
await page.getByRole('button', { name: 'Toggle empty' }).click();
|
||||
await chart.focus();
|
||||
await page.keyboard.press('End');
|
||||
assert.equal(await live.textContent(), '');
|
||||
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 0);
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close(); browser = null;
|
||||
}
|
||||
fs.writeFileSync(path.join(artifacts, process.argv.includes('--parent') ? 'parent.json' : 'result.json'), JSON.stringify({ playwright: require('playwright/package.json').version, observations }, null, 2));
|
||||
console.log(JSON.stringify({ result: 'passed', states: observations.length }));
|
||||
} finally { if (browser) await browser.close(); await server.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
1
frontend-modern/browser-tests/history-keyboard.html
Normal file
1
frontend-modern/browser-tests/history-keyboard.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History keyboard verification</title></head><body><div id="root"></div><script type="module" src="./history-keyboard.tsx"></script></body></html>
|
||||
48
frontend-modern/browser-tests/history-keyboard.tsx
Normal file
48
frontend-modern/browser-tests/history-keyboard.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart';
|
||||
import '../src/index.css';
|
||||
const Fixture = () => {
|
||||
const [empty, setEmpty] = createSignal(false);
|
||||
const [target, setTarget] = createSignal('a');
|
||||
const points = () =>
|
||||
empty()
|
||||
? []
|
||||
: [10, 20, 30].map((value, i) => ({
|
||||
timestamp: 1790942400000 + i * 60000,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
}));
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1>Storage History keyboard inspection</h1>
|
||||
<button onClick={() => setEmpty(!empty())}>Toggle empty</button>
|
||||
<button onClick={() => setTarget(target() === 'a' ? 'b' : 'a')}>Change target</button>
|
||||
<HistoryChartHoverGroup>
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId={target()}
|
||||
metric="usage"
|
||||
label="Usage"
|
||||
unit="%"
|
||||
hideSelector
|
||||
range="1h"
|
||||
data={points()}
|
||||
/>
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId={target()}
|
||||
metric="diskread"
|
||||
label="Read"
|
||||
unit="B/s"
|
||||
hideSelector
|
||||
range="1h"
|
||||
data={points()}
|
||||
/>
|
||||
</HistoryChartHoverGroup>
|
||||
<button>After charts</button>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
93
frontend-modern/browser-tests/history-status.cjs
Normal file
93
frontend-modern/browser-tests/history-status.cjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const artifacts = path.join(root, 'node_modules/history-status-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5224, strictPort: true } });
|
||||
let browser;
|
||||
const observations = [];
|
||||
const parent = process.argv.includes('--parent');
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
|
||||
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
let mode = 'empty', calls = 0;
|
||||
const pending = [];
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5224') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname.includes('/metrics-store/history')) {
|
||||
calls++;
|
||||
if (mode === 'pending') { pending.push(route); return; }
|
||||
if (mode === 'failed') return route.fulfill({status: 503, json: {error: 'Synthetic unavailable'}});
|
||||
return route.fulfill({json: {points: mode === 'empty' ? [] : [0,1,2].map(i => ({timestamp: 1790942400000 + i*60000, value: 20+i*10, min: 20+i*10, max: 20+i*10})), source: 'store'}});
|
||||
}
|
||||
return route.fulfill({json: url.pathname === '/api/license/runtime-capabilities' ? {capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: {build:'community'}, blocked_capabilities: []} : {data: [], enabled: false}});
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.goto('http://127.0.0.1:5224/browser-tests/history-status.html');
|
||||
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const chart = page.getByRole('img', {name:'Usage chart', exact:true});
|
||||
await chart.waitFor();
|
||||
const description = page.locator('#' + await chart.getAttribute('aria-describedby'));
|
||||
async function state(phase, expected) {
|
||||
await page.waitForFunction(({id, expected}) => document.getElementById(id).textContent.includes(expected), {id: await chart.getAttribute('aria-describedby'), expected});
|
||||
const actual = await description.textContent();
|
||||
const dimensions = await page.evaluate(() => ({scroll:document.documentElement.scrollWidth, width:innerWidth}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1);
|
||||
await page.screenshot({path:path.join(artifacts, `${parent?'parent-':''}${name}-${phase}.png`),fullPage:true});
|
||||
observations.push({name, version:browser.version(),phase,actual,dimensions,calls});
|
||||
}
|
||||
await state('empty', 'No 1-hour');
|
||||
assert.equal(await page.getByText(parent ? 'Collecting data... History will appear here.' : 'No history samples in this time range.', {exact:true}).count(),1);
|
||||
mode = 'populated';
|
||||
await page.clock.runFor(10_000);
|
||||
await state('loaded','40.0%');
|
||||
mode = 'failed';
|
||||
await page.clock.runFor(10_000);
|
||||
if (parent) {
|
||||
await page.waitForTimeout(100);
|
||||
assert.ok(!(await description.textContent()).includes('Could not refresh'));
|
||||
assert.equal(await page.getByText('Could not refresh history. Showing the last successful result.',{exact:true}).count(),0);
|
||||
await state('silent-failure','40.0%');
|
||||
await browser.close(); browser = null; break;
|
||||
}
|
||||
await state('refresh-failed','Could not refresh history');
|
||||
assert.ok((await description.textContent()).includes('40.0%'));
|
||||
assert.ok((await page.getByRole('status', {name:'History refresh status'}).textContent()).includes('last successful result'));
|
||||
mode = 'pending';
|
||||
await page.clock.runFor(10_000);
|
||||
await state('retry-pending','Could not refresh history');
|
||||
for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20));
|
||||
assert.ok(pending.length > 0, 'pending refresh observed');
|
||||
for (const route of pending.splice(0)) await route.fulfill({json:{points:[],source:'store'}});
|
||||
await state('recovered-empty','No 1-hour');
|
||||
assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
|
||||
mode = 'failed';
|
||||
await page.clock.runFor(10_000);
|
||||
await state('empty-refresh-failed','Could not refresh history');
|
||||
mode = 'pending';
|
||||
await page.getByRole('button',{name:'Select pool b'}).click();
|
||||
await state('target-loading','Loading');
|
||||
assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
|
||||
for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20));
|
||||
assert.ok(pending.length > 0, 'pending refresh observed');
|
||||
for (const route of pending.splice(0)) await route.fulfill({status:503,json:{error:'Synthetic unavailable'}});
|
||||
await state('initial-failed','could not be loaded');
|
||||
assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
|
||||
assert.deepEqual(errors,[]);
|
||||
await browser.close(); browser=null;
|
||||
}
|
||||
fs.writeFileSync(path.join(artifacts,parent?'parent.json':'result.json'),JSON.stringify({playwright:require('playwright/package.json').version,observations},null,2));
|
||||
console.log(JSON.stringify({result:'passed',states:observations.length,parent}));
|
||||
} finally { if(browser) await browser.close(); await server.close(); }
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
1
frontend-modern/browser-tests/history-status.html
Normal file
1
frontend-modern/browser-tests/history-status.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History request states verification</title></head><body><div id="root"></div><script type="module" src="./history-status.tsx"></script></body></html>
|
||||
30
frontend-modern/browser-tests/history-status.tsx
Normal file
30
frontend-modern/browser-tests/history-status.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { HistoryChart } from '../src/components/shared/HistoryChart';
|
||||
import '../src/index.css';
|
||||
const Fixture = () => {
|
||||
const [target, setTarget] = createSignal('a');
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1>Storage History request states</h1>
|
||||
<p>Synthetic transport; production History chart. Selected pool: {target()}</p>
|
||||
<button class="min-h-11 border px-3" onClick={() => setTarget('b')}>
|
||||
Select pool b
|
||||
</button>
|
||||
<section class="max-w-xl">
|
||||
<HistoryChart
|
||||
resourceType="storage"
|
||||
resourceId={target()}
|
||||
metric="usage"
|
||||
label="Usage"
|
||||
unit="%"
|
||||
hideSelector
|
||||
compact
|
||||
range="1h"
|
||||
height={140}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
|
|
@ -1,16 +1,26 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "abf6bcec5b6b94d181264dae62a9983c48619ac6",
|
||||
"verified_at": "2026-10-02T16:04:03.580037Z",
|
||||
"base_sha": "a3f3b7564e6576831d87a5ea99cd08d77dd00549",
|
||||
"verified_at": "2026-10-02T18:10:48Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts"
|
||||
"frontend-modern/src/components/shared/HistoryChart.tsx",
|
||||
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
|
||||
"frontend-modern/src/components/shared/historyChartModel.ts",
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts",
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts": "f10b5cfc7cb296c60ad7e87bf7c3001c4308d29af702b8c7a7e77e7c4c4c3fb6"
|
||||
"frontend-modern/src/components/shared/HistoryChart.tsx": "a2ab0198a705e66ce9bea222066078709c8334fc342415916b8c179987b139e2",
|
||||
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "4192731461a19ba7c33185c2e616c5023dfef58d93e3893cd3c4fceb58598391",
|
||||
"frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751",
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137",
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/history-selection.html"
|
||||
"/browser-tests/disk-temperature.html",
|
||||
"/browser-tests/history-keyboard.html",
|
||||
"/browser-tests/history-status.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -23,29 +33,21 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"Synthetic delayed responses in the production shared History chart, not native PBS acceptance",
|
||||
"B remains at 80% after late A=10%; C loading and failure clear B readings and accessible description",
|
||||
"Desktop Chromium light and phone WebKit dark; no page errors or horizontal overflow"
|
||||
"Temperature-only, hot, Fahrenheit, missing and extended-SMART production detail snapshots",
|
||||
"Keyboard chart focus, previous/first/last/bounds/Escape, shared wrapped hover tooltip, empty state",
|
||||
"Empty success, populated History, failed refresh, pending retry, recovered empty, failed empty refresh, selected-target loading and initial failure"
|
||||
],
|
||||
"interactions": [
|
||||
"Select B and complete it before A; complete stale A",
|
||||
"Select C and fail its request; inspect visible and accessible states"
|
||||
"On Chromium and WebKit, switch disk snapshots and temperature units; check severity, absent counters, accessible fallback and retained counters",
|
||||
"Tab between charts and following control; inspect observations with arrows, Home, End and Escape; verify active-chart-only polite announcements and visible focus",
|
||||
"Drive synthetic History HTTP success/503/pending/recovery and pool change; retain prior successful source/values with a refresh warning until replacement",
|
||||
"Inspect both full keyboard screenshots and all temperature/request-state screenshots through retained contact sheets; no horizontal overflow or uncaught page errors observed"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-selection.cjs",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-b-loaded.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-c-failed.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-c-loading.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/chromium-old-a-finished.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-b-loaded.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-c-failed.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-c-loading.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/release-line-3nnbyhhx/browser-final-v6.5/webkit-old-a-finished.png"
|
||||
]
|
||||
"playwright": "1.56.1",
|
||||
"browser_versions": [
|
||||
"chromium 141.0.7390.37",
|
||||
"webkit 26.0"
|
||||
],
|
||||
"integration_frontier": "ea3b7a896527eddcadc07aa57dd5f80f4886a947",
|
||||
"proof_scope": "Fresh owning-line browser proof on the composed candidate; all five runtime files in the complete assigned-base integration delta. The receipt is bound to this non-merge commit's own parent, not re-parented historical proof."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createSignal, type JSX } from 'solid-js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DiskDetail } from '@/components/Storage/DiskDetail';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
|
@ -107,9 +107,38 @@ describe('DiskDetail', () => {
|
|||
expect(screen.queryByText(/:diskwrite:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an explicit overview fallback when SMART details are unavailable', () => {
|
||||
it('keeps standalone temperature readings visible through snapshot replacement', () => {
|
||||
const initial = buildDisk();
|
||||
delete initial.physicalDisk!.smart;
|
||||
const [disk, setDisk] = createSignal(initial);
|
||||
render(() => <DiskDetail disk={disk()} nodes={[]} />);
|
||||
|
||||
expect(screen.getByText('42°C')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Power-On Time')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Detailed SMART attributes are not available for this disk.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature: 65 } });
|
||||
expect(screen.getByText('65°C')).toHaveClass('text-red-600');
|
||||
expect(screen.queryByText('42°C')).not.toBeInTheDocument();
|
||||
|
||||
setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature: 0 } });
|
||||
expect(screen.queryByText('Temperature')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Detailed SMART attributes are not available for this disk.'),
|
||||
).toHaveAttribute('role', 'status');
|
||||
|
||||
setDisk(buildDisk());
|
||||
expect(screen.getByText('42°C')).toBeInTheDocument();
|
||||
expect(screen.getByText('Power-On Time')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an explicit overview fallback when no detail readings are available', () => {
|
||||
const disk = buildDisk();
|
||||
delete disk.physicalDisk!.smart;
|
||||
delete disk.physicalDisk!.temperature;
|
||||
|
||||
render(() => <DiskDetail disk={disk} nodes={[]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Component, createMemo, createUniqueId } from 'solid-js';
|
||||
import {
|
||||
formatHistoryChartTooltipValue,
|
||||
getHistoryChartAccessibleDescription,
|
||||
getHistoryChartAccessibleLabel,
|
||||
type HistoryChartProps,
|
||||
|
|
@ -27,6 +28,9 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
|||
},
|
||||
hoverGroup,
|
||||
);
|
||||
const refreshStatus = createMemo(() =>
|
||||
chart.refreshFailed() ? 'Could not refresh history. Showing the last successful result.' : '',
|
||||
);
|
||||
const accessibleDescription = createMemo(() =>
|
||||
getHistoryChartAccessibleDescription({
|
||||
data: chart.data(),
|
||||
|
|
@ -50,13 +54,25 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
|||
unit={props.unit}
|
||||
/>
|
||||
|
||||
<p
|
||||
role="status"
|
||||
aria-label="History refresh status"
|
||||
class="text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{refreshStatus()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class={`relative flex-1 w-full ${props.compact ? 'min-h-[120px]' : 'min-h-[200px]'}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block w-full h-full cursor-crosshair"
|
||||
class="block w-full h-full cursor-crosshair rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500"
|
||||
tabIndex={0}
|
||||
onFocus={chart.handleFocus}
|
||||
onBlur={chart.handleBlur}
|
||||
onKeyDown={chart.handleKeyDown}
|
||||
role="img"
|
||||
aria-label={getHistoryChartAccessibleLabel(props.label)}
|
||||
aria-describedby={descriptionId}
|
||||
|
|
@ -64,7 +80,13 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
|||
onMouseLeave={chart.handleMouseLeave}
|
||||
/>
|
||||
<p id={descriptionId} class="sr-only">
|
||||
{accessibleDescription()}
|
||||
{refreshStatus()} {accessibleDescription()} Use Left and Right arrow keys to inspect
|
||||
readings, Home and End for the first and last reading, and Escape to clear inspection.
|
||||
</p>
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{chart.keyboardInspecting() && chart.hoveredPoint()
|
||||
? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}`
|
||||
: ''}
|
||||
</p>
|
||||
<HistoryChartOverlay chart={chart} hideLock={props.hideLock} />
|
||||
<HistoryChartTooltip
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props)
|
|||
<path d="M21 21v-5h-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">Collecting data... History will appear here.</p>
|
||||
<p class="text-sm text-slate-500">No history samples in this time range.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -5283,7 +5283,8 @@ describe('shared primitive guardrails', () => {
|
|||
expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
|
||||
|
||||
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
|
||||
expect(historyChartOverlaySource).toContain('No history samples in this time range.');
|
||||
expect(historyChartOverlaySource).not.toContain('History will appear here.');
|
||||
expect(historyChartOverlaySource).toContain(
|
||||
'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ describe('HistoryChart', () => {
|
|||
expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
|
||||
|
||||
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
|
||||
expect(historyChartOverlaySource).toContain('No history samples in this time range.');
|
||||
expect(historyChartOverlaySource).not.toContain('History will appear here.');
|
||||
expect(historyChartOverlaySource).toContain(
|
||||
'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.',
|
||||
);
|
||||
|
|
@ -130,6 +131,56 @@ describe('HistoryChart', () => {
|
|||
expect(historyChartTooltipSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
});
|
||||
|
||||
it('inspects actual readings with the keyboard and clears on escape, blur and selection changes', () => {
|
||||
const [target, setTarget] = createSignal('a');
|
||||
const [points, setPoints] = createSignal([
|
||||
{ timestamp: 1_000, value: 10, min: 10, max: 10 },
|
||||
{ timestamp: 2_000, value: 20, min: 20, max: 20 },
|
||||
{ timestamp: 3_000, value: 30, min: 30, max: 30 },
|
||||
]);
|
||||
const { container } = render(() => (
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId={target()}
|
||||
metric="usage"
|
||||
unit="%"
|
||||
hideSelector
|
||||
data={points()}
|
||||
/>
|
||||
));
|
||||
const canvas = screen.getByRole('img', { name: 'History chart' });
|
||||
const announcement = container.querySelector('[aria-live="polite"]')!;
|
||||
expect(canvas).toHaveAttribute('tabindex', '0');
|
||||
expect(announcement.textContent).toBe('');
|
||||
fireEvent.focus(canvas);
|
||||
expect(announcement).toHaveTextContent('30.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
|
||||
expect(announcement).toHaveTextContent('20.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'Home' });
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
|
||||
expect(announcement).toHaveTextContent('10.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight', ctrlKey: true });
|
||||
expect(announcement).toHaveTextContent('10.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'End' });
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight' });
|
||||
expect(announcement).toHaveTextContent('30.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'Escape' });
|
||||
expect(announcement.textContent).toBe('');
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
|
||||
fireEvent.keyDown(canvas, { key: 'Home' });
|
||||
setPoints(points().map((point) => ({ ...point, value: point.value + 1 })));
|
||||
expect(announcement).toHaveTextContent('11.0%');
|
||||
setTarget('b');
|
||||
expect(announcement.textContent).toBe('');
|
||||
fireEvent.keyDown(canvas, { key: 'Home' });
|
||||
fireEvent.blur(canvas);
|
||||
expect(announcement.textContent).toBe('');
|
||||
setPoints([]);
|
||||
fireEvent.focus(canvas);
|
||||
fireEvent.keyDown(canvas, { key: 'End' });
|
||||
expect(announcement.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('renders the default history label', () => {
|
||||
render(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);
|
||||
|
||||
|
|
@ -272,7 +323,8 @@ describe('HistoryChart', () => {
|
|||
|
||||
expect(layout.x).toBe(162);
|
||||
expect(layout.x).toBeGreaterThan(150);
|
||||
expect(layout.y).toBe(47);
|
||||
expect(layout.height).toBe(64);
|
||||
expect(layout.y + layout.height / 2).toBe(70);
|
||||
});
|
||||
|
||||
it('moves the tooltip to the left edge side near the right chart boundary', () => {
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ describe('getHistoryChartTooltipLayout', () => {
|
|||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout).toStrictEqual({ x: 162, y: 47, width: 156, height: 46 });
|
||||
expect(layout).toStrictEqual({ x: 162, y: 38, width: 156, height: 64 });
|
||||
});
|
||||
|
||||
it('places the tooltip to the left when only the left side has room', () => {
|
||||
|
|
@ -537,7 +537,7 @@ describe('getHistoryChartTooltipLayout', () => {
|
|||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout).toStrictEqual({ x: 12, y: 12, width: 156, height: 46 });
|
||||
expect(layout).toStrictEqual({ x: 12, y: 82, width: 156, height: 64 });
|
||||
});
|
||||
|
||||
it('pushes an overlapping tooltip above the hovered point when there is headroom above', () => {
|
||||
|
|
@ -558,6 +558,6 @@ describe('getHistoryChartTooltipLayout', () => {
|
|||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout.y).toBe(126);
|
||||
expect(layout.y + layout.height).toBe(172);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,25 +117,56 @@ describe('History request ownership', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('does not overlap polling and preserves matching samples on refresh failure', async () => {
|
||||
const initial = deferred();
|
||||
request.mockReturnValueOnce(initial.promise);
|
||||
const { state } = mount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
initial.resolve({ points: points(10), source: 'store' } as never);
|
||||
await settle();
|
||||
const refresh = deferred();
|
||||
request.mockReturnValueOnce(refresh.promise);
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
refresh.reject(new Error('refresh failed'));
|
||||
await settle();
|
||||
expect(state.data()).toEqual(points(10));
|
||||
expect(state.error()).toBeNull();
|
||||
expect(state.loading()).toBe(false);
|
||||
});
|
||||
it.each([{ samples: points(10) }, { samples: [] }])(
|
||||
'retains the last result %j and exposes refresh failure until recovery',
|
||||
async ({ samples }) => {
|
||||
const initial = deferred();
|
||||
request.mockReturnValueOnce(initial.promise);
|
||||
const { state } = mount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
initial.resolve({ points: samples, source: 'store' } as never);
|
||||
await settle();
|
||||
const refresh = deferred();
|
||||
request.mockReturnValueOnce(refresh.promise);
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
refresh.reject(new Error('refresh failed'));
|
||||
await settle();
|
||||
expect(state.data()).toEqual(samples);
|
||||
expect(state.error()).toBeNull();
|
||||
expect(state.refreshFailed()).toBe(true);
|
||||
expect(state.source()).toBe('store');
|
||||
expect(state.loading()).toBe(false);
|
||||
const recovery = deferred();
|
||||
request.mockReturnValueOnce(recovery.promise);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(state.refreshFailed()).toBe(true);
|
||||
recovery.resolve({ points: points(30), source: 'memory' } as never);
|
||||
await settle();
|
||||
expect(state.refreshFailed()).toBe(false);
|
||||
expect(state.data()).toEqual(points(30));
|
||||
expect(state.source()).toBe('memory');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ resourceId: 'b' }, { range: '6h' }, { data: [] }, { range: '90d' }])(
|
||||
'clears refresh failure on selection replacement %j',
|
||||
async (next) => {
|
||||
request.mockResolvedValueOnce({ points: points(10), source: 'store' } as never);
|
||||
const { state, change } = mount();
|
||||
await settle();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
request.mockRejectedValueOnce(new Error('refresh failed'));
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(state.refreshFailed()).toBe(true);
|
||||
request.mockReturnValueOnce(deferred().promise);
|
||||
change(next as Partial<HistoryChartProps>);
|
||||
expect(state.refreshFailed()).toBe(false);
|
||||
expect(state.data()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('cancels fetched data when supplied data takes ownership, including empty samples', async () => {
|
||||
const old = deferred();
|
||||
|
|
|
|||
|
|
@ -279,7 +279,8 @@ export function getHistoryChartTooltipLayout({
|
|||
chartHeight: number;
|
||||
}): HistoryChartTooltipLayout {
|
||||
const width = 156;
|
||||
const height = 46;
|
||||
// Allow a wrapped local timestamp plus the reading at normal text size.
|
||||
const height = 64;
|
||||
const margin = 8;
|
||||
const pointGap = 12;
|
||||
const minX = margin;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export function useHistoryChartState(
|
|||
) {
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
|
||||
const [data, setData] = createSignal(props.data ?? []);
|
||||
const [keyboardInspecting, setKeyboardInspecting] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [refreshFailed, setRefreshFailed] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
|
||||
null,
|
||||
|
|
@ -118,6 +120,7 @@ export function useHistoryChartState(
|
|||
setData(suppliedData ?? []);
|
||||
setSource(suppliedData !== undefined ? 'live' : null);
|
||||
setError(null);
|
||||
setRefreshFailed(false);
|
||||
setLoading(false);
|
||||
const selection = JSON.stringify([
|
||||
resourceType,
|
||||
|
|
@ -153,12 +156,13 @@ export function useHistoryChartState(
|
|||
if (!active) return;
|
||||
setData('points' in result ? (result.points ?? []) : []);
|
||||
setSource(result.source ?? 'store');
|
||||
setRefreshFailed(false);
|
||||
hasLoaded = true;
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
console.error('Failed to fetch metrics history:', err);
|
||||
if (!hasLoaded) setError('Failed to load history data');
|
||||
setSource(null);
|
||||
if (hasLoaded) setRefreshFailed(true);
|
||||
else setError('Failed to load history data');
|
||||
} finally {
|
||||
if (active) {
|
||||
pending = false;
|
||||
|
|
@ -351,11 +355,47 @@ export function useHistoryChartState(
|
|||
onCleanup(() => resizeObserver.disconnect());
|
||||
});
|
||||
|
||||
const handleFocus = () => {
|
||||
setKeyboardInspecting(true);
|
||||
const points = data();
|
||||
setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setKeyboardInspecting(false);
|
||||
setHoveredTimestamp(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
if (event.key === 'Escape') {
|
||||
setHoveredTimestamp(null);
|
||||
return;
|
||||
}
|
||||
const points = data();
|
||||
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
setKeyboardInspecting(true);
|
||||
const timestamp = hoveredTimestamp();
|
||||
const index =
|
||||
timestamp === null
|
||||
? points.length - 1
|
||||
: points.indexOf(findHistoryChartClosestPoint(points, timestamp));
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
setHoveredTimestamp(points[next].timestamp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const canvas = refs.getCanvas();
|
||||
const points = data();
|
||||
if (!canvas || points.length === 0) return;
|
||||
|
||||
setKeyboardInspecting(false);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
|
|
@ -370,6 +410,7 @@ export function useHistoryChartState(
|
|||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (keyboardInspecting()) return;
|
||||
setHoveredTimestamp(null);
|
||||
};
|
||||
|
||||
|
|
@ -378,6 +419,11 @@ export function useHistoryChartState(
|
|||
dataMax,
|
||||
dataMin,
|
||||
error,
|
||||
refreshFailed,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
keyboardInspecting,
|
||||
handleMouseLeave,
|
||||
handleMouseMove,
|
||||
chartHeight,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { temperatureStore } from '@/utils/temperature';
|
||||
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
getDiskDetailAttributeCards,
|
||||
|
|
@ -53,6 +54,40 @@ describe('diskDetailPresentation.branchcov0724pm', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('independently reported temperature', () => {
|
||||
it.each(['hdd', 'ssd', 'nvme'])('shows %s temperature without extended SMART data', (type) => {
|
||||
expect(getDiskDetailAttributeCards(makeDiskData({ type, temperature: 42 }))).toEqual([
|
||||
{ label: 'Temperature', value: '42°C', ok: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([0, -1, NaN, Infinity, -Infinity])('does not invent a card for %s', (temperature) => {
|
||||
expect(getDiskDetailAttributeCards(makeDiskData({ temperature }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps configured thresholds and display units independent of attribute presence', () => {
|
||||
const disk = makeDiskData({ type: 'nvme', temperature: 65 });
|
||||
expect(getDiskDetailAttributeCards(disk, { warning: 70, critical: 80 })).toEqual([
|
||||
{ label: 'Temperature', value: '65°C', ok: true },
|
||||
]);
|
||||
temperatureStore.setUnit('fahrenheit');
|
||||
try {
|
||||
expect(getDiskDetailAttributeCards(disk, { warning: 50, critical: 60 })).toEqual([
|
||||
{ label: 'Temperature', value: '149°F', ok: false },
|
||||
]);
|
||||
} finally {
|
||||
temperatureStore.setUnit('celsius');
|
||||
}
|
||||
});
|
||||
|
||||
it('treats empty and missing SMART data alike without inventing counters', () => {
|
||||
const disk = makeDiskData({ temperature: 42 });
|
||||
expect(getDiskDetailAttributeCards({ ...disk, smartAttributes: {} })).toEqual(
|
||||
getDiskDetailAttributeCards(disk),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDiskDetailAttributeCards SATA offlineUncorrectable arm (L129)', () => {
|
||||
it('emits an Offline Uncorrectable card marked ok when the count is zero', () => {
|
||||
const cards = getDiskDetailAttributeCards(
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ export function getDiskDetailAttributeCards(
|
|||
disk: PhysicalDiskPresentationData,
|
||||
diskTempThresholds?: MetricDisplayThresholds | null,
|
||||
): DiskDetailAttributeCard[] {
|
||||
const attrs = disk.smartAttributes;
|
||||
if (!attrs) return [];
|
||||
// Temperature is reported independently of optional extended SMART attributes.
|
||||
const attrs = disk.smartAttributes ?? {};
|
||||
|
||||
const cards: DiskDetailAttributeCard[] = [];
|
||||
const isNvme = disk.type?.toLowerCase() === 'nvme';
|
||||
|
|
@ -96,7 +96,7 @@ export function getDiskDetailAttributeCards(
|
|||
});
|
||||
}
|
||||
|
||||
if (disk.temperature > 0) {
|
||||
if (Number.isFinite(disk.temperature) && disk.temperature > 0) {
|
||||
cards.push({
|
||||
label: 'Temperature',
|
||||
value: formatTemperature(disk.temperature),
|
||||
|
|
|
|||
|
|
@ -2452,7 +2452,8 @@ describe('frontend resource type boundaries', () => {
|
|||
expect(historyChartModelSource).toContain('HISTORY_CHART_RANGES');
|
||||
expect(historyChartModelSource).toContain('getHistoryChartScale');
|
||||
expect(historyChartHeaderSource).toContain('formatHistoryChartTooltipValue');
|
||||
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
|
||||
expect(historyChartOverlaySource).toContain('No history samples in this time range.');
|
||||
expect(historyChartOverlaySource).not.toContain('History will appear here.');
|
||||
expect(historyChartOverlaySource).not.toContain('presentationPolicyHidesUpgradePrompts');
|
||||
expect(historyChartOverlaySource).not.toContain(
|
||||
'Unlock {props.chart.lockTierLabel()} Features',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue