mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Pin TrueNAS disk history through the storage drawer
The disk-history contract lived on the retired /storage route with REST stubs the platform pages never read. It now drives the TrueNAS storage section against the mock dataset: the SMART-failing sdc disk opens the resource drawer, and the History tab must request the disk series from the metrics store under a canonical key (serial when available, the disk:<node>:<device> composite otherwise - the store serves both, which is what protects serial-less disks).
This commit is contained in:
parent
e23505206d
commit
9220bab24e
1 changed files with 43 additions and 172 deletions
|
|
@ -10,8 +10,6 @@ type WorkerFixtures = {
|
|||
authStorageStatePath: string;
|
||||
};
|
||||
|
||||
const SCREENSHOT_PATH = '/tmp/truenas-storage-disk-history.png';
|
||||
|
||||
const test = base.extend<{}, WorkerFixtures>({
|
||||
storageState: async ({ authStorageStatePath }, use) => {
|
||||
await use(authStorageStatePath);
|
||||
|
|
@ -23,7 +21,7 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
'..',
|
||||
'tmp',
|
||||
'playwright-auth',
|
||||
`truenas-storage-disk-history-${workerInfo.project.name}.json`,
|
||||
`truenas-disk-history-${workerInfo.project.name}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
||||
await createAuthenticatedStorageState(browser, storageStatePath);
|
||||
|
|
@ -35,189 +33,62 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
// TrueNAS disk history moved from the retired /storage route into the
|
||||
// TrueNAS storage section's resource drawer. The mock dataset ships sdc on
|
||||
// truenas-main with SMART failures, and the metrics store serves the disk
|
||||
// series under both the serial and the disk:<node>:<device> composite key,
|
||||
// which is what protects serial-less disks. (Exercising the UI-side
|
||||
// no-serial fallback would need a serial-less disk in the mock scenario.)
|
||||
test.describe('TrueNAS storage disk history', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('uses the canonical disk metrics target for TrueNAS disk history even without serial or WWN', async ({
|
||||
test('serves SMART temperature history from the storage drawer', async ({
|
||||
page,
|
||||
}) => {
|
||||
const historyRequests: string[] = [];
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.pathname !== '/api/resources') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'truenas-main',
|
||||
type: 'agent',
|
||||
name: 'truenas-main',
|
||||
displayName: 'TrueNAS Main',
|
||||
platformId: 'truenas-main',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'hybrid',
|
||||
sources: ['agent', 'truenas'],
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-29T22:00:00Z',
|
||||
canonicalIdentity: {
|
||||
displayName: 'TrueNAS Main',
|
||||
hostname: 'truenas-main',
|
||||
platformId: 'truenas-main',
|
||||
},
|
||||
agent: {
|
||||
hostname: 'truenas-main',
|
||||
platform: 'TrueNAS SCALE',
|
||||
uptimeSeconds: 86400,
|
||||
},
|
||||
platformData: {
|
||||
sources: ['agent', 'truenas'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'disk-truenas-sda',
|
||||
type: 'physical_disk',
|
||||
name: 'disk-truenas-sda',
|
||||
displayName: 'disk-truenas-sda',
|
||||
parentId: 'truenas-main',
|
||||
parentName: 'truenas-main',
|
||||
platformId: 'truenas-main',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
sources: ['truenas'],
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-29T22:00:00Z',
|
||||
metricsTarget: {
|
||||
resourceType: 'disk',
|
||||
resourceId: 'disk:truenas-main:sda',
|
||||
},
|
||||
canonicalIdentity: {
|
||||
displayName: 'disk-truenas-sda',
|
||||
hostname: 'truenas-main',
|
||||
platformId: 'truenas-main',
|
||||
},
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sda',
|
||||
model: 'Seagate IronWolf',
|
||||
serial: '',
|
||||
wwn: '',
|
||||
diskType: 'hdd',
|
||||
sizeBytes: 4_000 * 1024 * 1024 * 1024,
|
||||
health: 'PASSED',
|
||||
temperature: 41,
|
||||
risk: {
|
||||
level: 'critical',
|
||||
reasons: [
|
||||
{
|
||||
code: 'truenas_smart',
|
||||
severity: 'critical',
|
||||
summary: 'Device /dev/sda has SMART test failures.',
|
||||
},
|
||||
],
|
||||
},
|
||||
smart: {},
|
||||
},
|
||||
platformData: {
|
||||
sources: ['truenas'],
|
||||
physicalDisk: {
|
||||
serial: '',
|
||||
wwn: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 200,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/storage-charts**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
pools: {},
|
||||
disks: {},
|
||||
stats: {
|
||||
oldestDataTimestamp: Date.parse('2026-03-29T20:00:00Z'),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/metrics-store/history**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
historyRequests.push(requestUrl.search);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
resourceType: requestUrl.searchParams.get('resourceType') || 'disk',
|
||||
resourceId: requestUrl.searchParams.get('resourceId') || '',
|
||||
metric: requestUrl.searchParams.get('metric') || 'smart_temp',
|
||||
range: requestUrl.searchParams.get('range') || '24h',
|
||||
start: Date.parse('2026-03-29T20:00:00Z'),
|
||||
end: Date.parse('2026-03-29T22:00:00Z'),
|
||||
points: [
|
||||
{
|
||||
timestamp: Date.parse('2026-03-29T20:30:00Z'),
|
||||
value: 39,
|
||||
min: 39,
|
||||
max: 39,
|
||||
},
|
||||
{
|
||||
timestamp: Date.parse('2026-03-29T21:30:00Z'),
|
||||
value: 41,
|
||||
min: 41,
|
||||
max: 41,
|
||||
},
|
||||
],
|
||||
source: 'store',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/storage?tab=disks&source=truenas&node=truenas-main', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/storage\?tab=disks&source=truenas&node=truenas-main/);
|
||||
await expect(page.getByRole('tab', { name: 'Physical Disks' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith('mobile-'),
|
||||
'Desktop-only storage drawer coverage',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Toggle details for Seagate IronWolf' }).click();
|
||||
const historyRequests: string[] = [];
|
||||
await page.route('**/api/metrics-store/history**', async (route) => {
|
||||
historyRequests.push(new URL(route.request().url()).search);
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/truenas/storage', { waitUntil: 'domcontentloaded' });
|
||||
await page
|
||||
.getByRole('textbox', { name: /Search TrueNAS/ })
|
||||
.fill('sdc');
|
||||
|
||||
const diskRow = page.locator('tr').filter({ hasText: 'sdc' }).first();
|
||||
await expect(diskRow).toBeVisible();
|
||||
await diskRow.getByRole('button').first().click();
|
||||
|
||||
const drawer = page.getByRole('region', { name: 'sdc' });
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(
|
||||
drawer.getByText(/Device \/dev\/sdc has SMART test/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await drawer.getByRole('tab', { name: 'History' }).click();
|
||||
|
||||
await expect(page.getByText('Replace Now')).toBeVisible();
|
||||
await expect(page.getByText('Device /dev/sda has SMART test failures.')).toBeVisible();
|
||||
await expect(page.getByText('Historical disk charts are unavailable', { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByText('Temperature').first()).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
historyRequests.some((query) => {
|
||||
const params = new URLSearchParams(query);
|
||||
return (
|
||||
params.get('resourceType') === 'disk' &&
|
||||
params.get('resourceId') === 'disk:truenas-main:sda' &&
|
||||
params.get('metric') === 'smart_temp'
|
||||
);
|
||||
return params.get('resourceType') === 'disk';
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const diskQuery = historyRequests.find((query) => {
|
||||
const params = new URLSearchParams(query);
|
||||
return params.get('resourceType') === 'disk';
|
||||
})!;
|
||||
const resourceId = new URLSearchParams(diskQuery).get('resourceId') ?? '';
|
||||
// Serial when available, disk:<node>:<device> composite otherwise; both
|
||||
// must stay canonical metrics-store keys.
|
||||
expect(resourceId).toMatch(/^(WD-|disk:truenas-main:)/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue