From 9220bab24ed237ae777a3de67504771b7bbe2329 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 8 Jul 2026 16:58:51 +0100 Subject: [PATCH] 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:: composite otherwise - the store serves both, which is what protects serial-less disks). --- .../27-truenas-storage-disk-history.spec.ts | 215 ++++-------------- 1 file changed, 43 insertions(+), 172 deletions(-) diff --git a/tests/integration/tests/27-truenas-storage-disk-history.spec.ts b/tests/integration/tests/27-truenas-storage-disk-history.spec.ts index d8d22d826..12af049e0 100644 --- a/tests/integration/tests/27-truenas-storage-disk-history.spec.ts +++ b/tests/integration/tests/27-truenas-storage-disk-history.spec.ts @@ -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:: 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:: composite otherwise; both + // must stay canonical metrics-store keys. + expect(resourceId).toMatch(/^(WD-|disk:truenas-main:)/); }); });