diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index b66c47ad32..e027d59a6f 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -185,6 +185,7 @@ the existing bounded HTTP client and artifact-size checks. 45. `frontend-modern/src/components/Workloads/useGuestRowState.ts` 46. `frontend-modern/src/components/Workloads/GuestDrawer.tsx` 47. `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx` +47a. `frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx` 48. `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx` 49. `frontend-modern/src/components/Workloads/guestDrawerModel.ts` 50. `frontend-modern/src/components/Workloads/useGuestDrawerState.ts` @@ -3107,6 +3108,17 @@ hover-preview, interval, or websocket work. Saving monitoring or lifecycle policy performs one mutation and one selected-resource refetch. The Workloads list, selectors, windowing, and polling budgets remain unchanged. +The same selected-guest boundary may read Unified Agent physical disks for an +open, agent-linked VM drawer. `GuestPhysicalDisks` uses the canonical guest +resource ID as an exact `parent` filter with `type=physical_disk`, and checks +each returned row's type and parent again before rendering SMART details. It +must not expand the Proxmox Overview's VM-only resource query, fetch for +uninstrumented or unopened guests, or add per-row, hover, interval, or +whole-estate physical-disk work to Workloads. The guest's existing agent RAID +snapshot remains local to the opened drawer; an empty or failed child query +must not imply that host disks belong to the guest. Focused hook and drawer +tests pin the query and no-data/error presentations. + ### Configuration transfer authorization stays off persistence hot paths Persisted and environment-backed SSO state is loaded into the router's auth diff --git a/frontend-modern/browser-tests/guest-storage-2263.cjs b/frontend-modern/browser-tests/guest-storage-2263.cjs new file mode 100644 index 0000000000..2b7592af45 --- /dev/null +++ b/frontend-modern/browser-tests/guest-storage-2263.cjs @@ -0,0 +1,89 @@ +// Browser proof for #2263. Uses the production guest overview and resource hook +// in Vite, with only the network response replaced by bounded fixture data. +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'browser-tests'); + process.chdir(root); + 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'), + server: { host: '127.0.0.1', port: 5199, strictPort: true }, + }); + let browser; + try { + await server.listen(); + browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] }); + const requests = []; + const pageErrors = []; + const disk = { + id: 'disk-guest-101', parentId: 'vm-resource-101', type: 'physical_disk', + name: 'guest-sata', displayName: 'Guest SATA', platformId: 'lab', + platformType: 'proxmox-pve', sourceType: 'agent', status: 'online', + lastSeen: new Date().toISOString(), + physicalDisk: { devPath: '/dev/sda', model: 'Guest SATA', diskType: 'sata', + sizeBytes: 1_000_000_000, health: 'PASSED', temperature: 45, + smart: { reallocatedSectors: 0, pendingSectors: 0, udmaCrcErrors: 2 } }, + }; + for (const width of [1440, 390]) { + const page = await browser.newPage({ viewport: { width, height: width === 390 ? 844 : 900 } }); + page.on('pageerror', (error) => pageErrors.push(`${width}: ${error.message}`)); + page.on('requestfailed', (request) => console.error('requestfailed', request.url(), request.failure())); + await page.routeWebSocket(/\/ws(?:\?|$)/, () => {}); + await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5199') return route.abort(); + if (url.pathname === '/api/resources') { + const parent = url.searchParams.get('parent'); + requests.push({ width, type: url.searchParams.get('type'), parent, + page: url.searchParams.get('page'), limit: url.searchParams.get('limit') }); + if (parent === 'vm-resource-error') return route.fulfill({ status: 503, json: { error: 'fixture' } }); + return route.fulfill({ json: { resources: [disk, { ...disk, id: 'host-disk', + parentId: 'pve-host' }], meta: { totalPages: 1 } } }); + } + if (url.pathname.startsWith('/api/')) return route.fulfill({ json: {} }); + return route.continue(); + }); + await page.goto('http://127.0.0.1:5199/browser-tests/guest-storage-2263.html', { + waitUntil: 'domcontentloaded', timeout: 120_000, + }); + await page.locator('[data-testid="guest-physical-disks"]').waitFor(); + assert.match(await page.locator('body').innerText(), /Physical Disks & SMART \(1\)/i); + assert.equal(await page.locator('[data-testid="guest-physical-disk"]').count(), 1); + assert.match(await page.locator('body').innerText(), /Guest RAID/i); + assert.match(await page.locator('body').innerText(), /raid1/); + await page.locator('[data-testid="guest-physical-disk"] summary').focus(); + await page.keyboard.press('Enter'); + await page.getByText('Reallocated Sectors').waitFor(); + await page.screenshot({ path: path.join(artifacts, `guest-storage-2263-${width}.png`), fullPage: true }); + const beforeNoAgent = requests.length; + await page.getByRole('button', { name: 'No agent' }).click(); + assert.equal(await page.locator('[data-testid="guest-physical-disks"]').count(), 0); + assert.equal(requests.length, beforeNoAgent, 'uninstrumented guest must not fetch physical disks'); + await page.getByRole('button', { name: 'Unavailable query' }).click(); + await page.getByText('Physical disk details are unavailable.').waitFor(); + assert.equal(await page.locator('[data-testid="guest-physical-disks"]').count(), 0); + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, + inner: window.innerWidth })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, `${width}px horizontal overflow: ${JSON.stringify(dimensions)}`); + await page.close(); + } + assert.equal(pageErrors.length, 0, `page errors: ${pageErrors.join('; ')}`); + assert.deepEqual(requests.map(({ type, parent, page, limit }) => ({ type, parent, page, limit })), [ + { type: 'physical_disk', parent: 'vm-resource-101', page: '1', limit: '100' }, + { type: 'physical_disk', parent: 'vm-resource-error', page: '1', limit: '100' }, + { type: 'physical_disk', parent: 'vm-resource-101', page: '1', limit: '100' }, + { type: 'physical_disk', parent: 'vm-resource-error', page: '1', limit: '100' }, + ]); + console.log(JSON.stringify({ result: 'passed', browser: browser.version(), + viewports: [1440, 390], route: '/browser-tests/guest-storage-2263.html', + requests, pageErrors })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/guest-storage-2263.html b/frontend-modern/browser-tests/guest-storage-2263.html new file mode 100644 index 0000000000..1f37793fc4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-storage-2263.html @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-storage-2263.tsx b/frontend-modern/browser-tests/guest-storage-2263.tsx new file mode 100644 index 0000000000..a9853a5a6a --- /dev/null +++ b/frontend-modern/browser-tests/guest-storage-2263.tsx @@ -0,0 +1,75 @@ +// #2263 browser fixture: mount the production guest overview and its real +// resource query, not a reimplementation of the disk/RAID presentation. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview'; +import '../src/index.css'; + +type Mode = 'linked' | 'none' | 'error'; +const [mode, setMode] = createSignal('linked'); +const baseGuest = { + id: 'vm-table-101', + canonicalResourceId: 'vm-resource-101', + name: 'guest-101', + vmid: 101, + type: 'qemu', + status: 'running', + cpus: 2, + node: 'pve-a', + instance: 'lab', + cpu: 0, + disk: { total: 10_000_000_000, used: 3_000_000_000, usage: 30 }, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: '2026-09-26T12:00:00Z', + memory: { total: 4_000_000_000, used: 1_000_000_000, free: 3_000_000_000, usage: 25 }, + disks: [], +}; +const linkedGuest = { + ...baseGuest, + agentKind: 'pulse', + agentRaid: [{ + device: '/dev/md0', name: 'data', level: 'raid1', state: 'clean', + totalDevices: 2, activeDevices: 2, workingDevices: 2, failedDevices: 0, + spareDevices: 0, devices: [], rebuildPercent: 0, + }], +}; +const errorGuest = { + ...linkedGuest, + canonicalResourceId: 'vm-resource-error', + agentRaid: [], +}; +const overviewProps = { + guestOsSummary: 'Debian 13', agentHeading: 'Agent', agentLabel: 'Connected', + agentTitle: 'Connected', hasAgentInfo: true, hasFilesystemDetails: false, + hasNetworkInterfaces: false, hasOsInfo: true, hasWorkloadActionAgent: false, + showInGuestAgentInstallCue: false, ipAddresses: [], networkInterfaces: [], + normalizedTags: [], backupPresentation: null, workloadActionAgentTitle: '', +}; + +render(() => ( +
+

Guest storage browser fixture

+ + + + + + + + + + +
+), document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index fb410d4e7a..0c8ed6c767 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,64 +1,47 @@ { "version": 1, - "base_sha": "d25403b8c3e28354d0e62fc3fc35898cca3e8f1c", - "verified_at": "2026-09-24T19:54:45Z", + "base_sha": "27036a244550f3095aa883a74716374107aaa6cd", + "verified_at": "2026-09-26T17:36:07Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", - "frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts", - "frontend-modern/src/components/shared/InlineNotice.tsx", - "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", - "frontend-modern/src/hooks/useUnifiedResources.ts", - "frontend-modern/src/hooks/useWorkloads.ts" + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/workloads.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "c112078ff621331510ac28490371ee7ba9099f7a1bbb64d0f66ff96400d4f89a", - "frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts": "3678e4b852ae0029760d3295fdd878cec436100f85c945dd46fe760da38887e4", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "7ae6fee729328039f4d1ade064ba5d6e899f0fdf0a72e96a7df7e034b4f464de", - "frontend-modern/src/components/shared/InlineNotice.tsx": "1a239f4240c57f83383f0d936f2e90ea6cb122627299e17500c20c7236c04e9d", - "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx": "7960f323dde72b2b2f9d961792d410db2bfd95bacd87b1ae55c1ed473737994e", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "808ba390e1978c07f08a033bc23d5cf4d62731b6cc8b388c024180b3914f6fce", - "frontend-modern/src/hooks/useUnifiedResources.ts": "c4d2f3c44b19c89d3cf0d15e033f825c37aaab985abfd963ddb5907cbab76fe2", - "frontend-modern/src/hooks/useWorkloads.ts": "453b90cfe6df925bd28a8713548c1348767f3c398d08c972b9b1f493e70986ef" + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f4f8903e442d92a622fa2517f54288fb0dfa97882136e268e8e0f540390127fe", + "frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "9640df56a51dbf27247613324a4f3fe1b4023e93d4a1efa913e234453acfd270", + "frontend-modern/src/hooks/useWorkloads.ts": "0b3ae83f83d2df3e2517d552f51b634e0eedecbfbd1756a2dadd834c81c67bd2", + "frontend-modern/src/types/workloads.ts": "4d779f40a56b83b83c8bbcb4b49f88e1db3432d071b8f6326094fa081547c1e6" }, "routes": [ - "/proxmox/overview", - "/proxmox/storage", - "/alerts/overview", - "/settings/infrastructure/agent-doctor" + "/browser-tests/guest-storage-2263.html" ], "viewports": [ { "width": 1440, "height": 900 }, - { - "width": 768, - "height": 900 - }, { "width": 390, "height": 844 } ], "states": [ - "50-node, 1,508-resource synthetic snapshot with two-second random metric updates and 49 outdated agents", - "Proxmox node selection, Workloads search/filter reset, Storage record Overview and History", - "Collapsed and keyboard-expanded affected-host notice, upgrade link and Agent Doctor guidance", - "Agent Doctor first and later table rows, badge and Seen values, keyboard row expansion and collapse", - "Mobile platform switcher open and Escape dismissed; alert Timeline expanded and dismissed", - "WebSocket reconnect and reload with no extra /api/state request on reconnect" + "Production guest Overview mounted with synthetic agent-linked VM, one guest SMART disk and mdadm RAID; fixture also returns an unrelated host disk to check parent isolation", + "Guest SMART disclosure collapsed and keyboard-expanded; health, temperature and counters visible", + "Uninstrumented VM: no physical-disk card and no child query", + "Child-query HTTP 503: visible unavailable status, no misleading empty card", + "Desktop and phone screenshots inspected; no horizontal overflow or browser page errors" ], "interactions": [ - "Hydrate Proxmox Overview, expand a node, filter 929 guests to one named container, clear the filter and navigate to Alerts.", - "Switch Storage and Overview through live controls, open a storage record, select History and inspect chart placement and mobile scrolling.", - "Focus and activate Show all 49 hosts by keyboard, inspect the full list, collapse it and use the upgrade link to open Agent Doctor.", - "Inspect Agent Doctor at 1440, 768 and 390px, including guidance contrast, full badge and Seen text, first and later rows, keyboard expansion and return to Proxmox.", - "Open the mobile platform switcher, inspect stacking and clipping, dismiss with Escape. Reconnect WebSocket and reload, confirming resource hydration.", - "Navigate to Alerts and expand Timeline at desktop and phone widths; inspect dismissal and adjacent controls." + "Load current Vite source build in Chromium and inspect the linked guest Overview at 1440px and 390px", + "Focus the physical-disk summary and press Enter; inspect rendered SMART counters and adjacent RAID card", + "Select No agent and verify that no physical-disk request or card is made", + "Select Unavailable query and verify the explicit error status with no disk card", + "Assert both requests use type=physical_disk and exact canonical parent ID, never the VMID/table ID; reject unrelated host disk in client display" ], - "command": "Playwright on the final rebuilt 50-node synthetic server at pulse-dev:7703 after the lazy registry index change: qa-agent-doctor-final-20260924.mjs, qa-final-20260924.mjs, qa-adjacent-20260924.mjs, qa-overlays-20260924.mjs, qa-notice-action-20260924.mjs.", - "notes": "Final frontend content hashes bind the receipt to every changed frontend runtime file. The browser binary used internal/unifiedresources/registry.go SHA-256 bb4fa4a0d9a635971c93d93282663bb8088e25e020c087e75de5a7b196c7222d. Pixel screenshots inspected at desktop, intermediate and phone widths, including the deepest changed states. No page errors or horizontal overflow. The synthetic fixture does not qualify installed-fleet latency or the remaining roughly 4 MB initial resource stream." + "command": "pulse-worker-browser frontend-modern/browser-tests/guest-storage-2263.cjs (Playwright 1.56.1, Chromium 141.0.7390.37)", + "notes": "Synthetic network fixture mounts production GuestDrawerOverview, GuestPhysicalDisks, RaidCard and useUnifiedResources in Vite; it does not prove installed Proxmox data, full-app navigation, release placement or reporter confirmation. Screenshots and browser command output retained in the lane output directory." } diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 39e66ca4a2..c7d95a9fae 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -24,6 +24,8 @@ import { } from './guestDrawerModel'; import type { NestedWorkloadContext } from './nestedWorkloadContext'; import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness'; +import { GuestPhysicalDisks } from './GuestPhysicalDisks'; +import { RaidCard } from '@/components/shared/cards/RaidCard'; import type { GuestDrawerProps } from './guestDrawerModel'; @@ -276,6 +278,18 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { dataTestId="guest-technical-details" sections={[...overviewSections(), ...technicalSections()]} /> + + + + + +
diff --git a/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx b/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx new file mode 100644 index 0000000000..e487da5254 --- /dev/null +++ b/frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx @@ -0,0 +1,83 @@ +import { For, Show, createSignal, type Component } from 'solid-js'; +import { DiskDetail } from '@/components/Storage/DiskDetail'; +import { InfoCardFrame } from '@/components/shared/InfoCardFrame'; +import { useUnifiedResources } from '@/hooks/useUnifiedResources'; +import { + extractPhysicalDiskPresentationData, + getPhysicalDiskHealthStatus, +} from '@/features/storageBackups/diskPresentation'; +import type { Resource } from '@/types/resource'; +import { formatBytes } from '@/utils/format'; +import { formatTemperature } from '@/utils/temperature'; + +const GuestPhysicalDiskRow: Component<{ disk: Resource }> = (props) => { + const [expanded, setExpanded] = createSignal(false); + const data = () => extractPhysicalDiskPresentationData(props.disk); + const health = () => getPhysicalDiskHealthStatus(data()); + const label = () => data().model || data().devPath || props.disk.name; + + return ( +
setExpanded(event.currentTarget.open)} + data-testid="guest-physical-disk" + > + + {label()} + + {data().devPath} + + + {health().label} + + 0}> + {formatTemperature(data().temperature)} + + 0}> + {formatBytes(data().size)} + + + +
+ +
+
+
+ ); +}; + +export const GuestPhysicalDisks: Component<{ parentId: string }> = (props) => { + // The Proxmox Overview snapshot deliberately excludes physical disks. Query + // only the open guest's direct children rather than hydrating every disk in + // a large estate (or mistaking the VMID-based table key for the resource ID). + const parentId = props.parentId.trim(); + const source = useUnifiedResources({ + query: `type=physical_disk&parent=${encodeURIComponent(parentId)}`, + cacheKey: `guest-physical-disks:${parentId}`, + enabled: () => parentId.length > 0, + }); + const disks = () => + source + .resources() + .filter((resource) => resource.type === 'physical_disk' && resource.parentId === parentId); + + return ( + <> + +

+ Physical disk details are unavailable. +

+
+ 0}> + +

+ Physical Disks & SMART ({disks().length}) +

+
+ {(disk) => } +
+
+
+ + ); +}; diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx index acaa858c3e..38e5e81bce 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx @@ -55,6 +55,34 @@ const props: ComponentProps = { describe('GuestDrawerOverview filesystem labels', () => { afterEach(cleanup); + it('shows linked-agent RAID arrays beside the guest storage evidence', () => { + render(() => ( + + )); + expect(screen.getByText('Guest RAID')).toBeInTheDocument(); + expect(screen.getByText('data')).toBeInTheDocument(); + expect(screen.getByText('raid1')).toBeInTheDocument(); + expect(screen.getByText('clean')).toBeInTheDocument(); + }); + it('shows shared-prefix paths above usage, including unknown usage, without relying on hover', () => { render(() => ); for (const path of paths) { diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestPhysicalDisks.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestPhysicalDisks.test.tsx new file mode 100644 index 0000000000..45dfc75287 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestPhysicalDisks.test.tsx @@ -0,0 +1,92 @@ +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { GuestPhysicalDisks } from '../GuestPhysicalDisks'; + +const queryState = vi.hoisted(() => ({ + resources: [] as Resource[], + error: undefined as unknown, + query: '', + enabled: false, +})); + +vi.mock('@/hooks/useUnifiedResources', () => ({ + useUnifiedResources: (options: { query: string; enabled: () => boolean }) => { + queryState.query = options.query; + queryState.enabled = options.enabled(); + return { resources: () => queryState.resources, error: () => queryState.error }; + }, +})); + +const disk: Resource = { + id: 'disk-101-a', + parentId: 'vm-resource-101', + type: 'physical_disk', + name: 'disk-101-a', + displayName: 'Guest SATA', + platformId: 'cluster-a', + platformType: 'proxmox-pve', + sourceType: 'agent', + status: 'online', + lastSeen: Date.now(), + physicalDisk: { + devPath: '/dev/sda', + model: 'Guest SATA', + diskType: 'sata', + sizeBytes: 1_000_000_000, + health: 'PASSED', + temperature: 45, + smart: { reallocatedSectors: 0, pendingSectors: 0, udmaCrcErrors: 2 }, + }, +}; + +describe('GuestPhysicalDisks', () => { + beforeEach(() => { + queryState.resources = []; + queryState.error = undefined; + queryState.query = ''; + queryState.enabled = false; + }); + afterEach(cleanup); + + it('shows the linked guest disk and opens its existing SMART detail', () => { + queryState.resources = [disk, { ...disk, id: 'host-disk', parentId: 'pve-node-1' }]; + render(() => ); + expect(queryState.query).toBe('type=physical_disk&parent=vm-resource-101'); + expect(queryState.enabled).toBe(true); + const card = screen.getByTestId('guest-physical-disks'); + expect(within(card).getByText('Physical Disks & SMART (1)')).toBeInTheDocument(); + expect(within(card).getByText('Guest SATA')).toBeInTheDocument(); + expect(within(card).getByText('Healthy')).toBeInTheDocument(); + expect(within(card).getByText('45°C')).toBeInTheDocument(); + expect(within(card).queryByText('Reallocated Sectors')).toBeNull(); + + const row = within(card).getByTestId('guest-physical-disk') as HTMLDetailsElement; + row.open = true; + fireEvent(row, new Event('toggle')); + expect(within(card).getByText('Reallocated Sectors')).toBeInTheDocument(); + expect(within(card).getByText('Pending Sectors')).toBeInTheDocument(); + expect(within(card).getByText('CRC Errors')).toBeInTheDocument(); + expect(within(card).getAllByTestId('guest-physical-disk')).toHaveLength(1); + }); + + it('does not add an empty storage card to an uninstrumented guest', () => { + render(() => ); + expect(screen.queryByTestId('guest-physical-disks')).toBeNull(); + }); + + it('encodes the parent value and never widens an empty parent to all disks', () => { + render(() => ); + expect(queryState.query).toBe('type=physical_disk&parent=vm%26101'); + cleanup(); + render(() => ); + expect(queryState.enabled).toBe(false); + expect(screen.queryByTestId('guest-physical-disks')).toBeNull(); + }); + + it('makes a failed child query visible rather than silently implying no disks', () => { + queryState.error = new Error('unavailable'); + render(() => ); + expect(screen.getByRole('status')).toHaveTextContent('Physical disk details are unavailable.'); + }); +}); diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index e3a758519c..fc20e938e7 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -78,6 +78,46 @@ describe('useWorkloads', () => { resetWorkloadsCacheForTests(); }); + it('preserves linked-agent RAID arrays from the VM resource for guest detail', async () => { + const [snapshot] = createSignal([ + { + ...sampleResource, + agent: { + agentVersion: '6.4.5', + raid: [ + { + device: '/dev/md0', + name: 'data', + level: 'raid1', + state: 'clean', + totalDevices: 2, + activeDevices: 2, + workingDevices: 2, + failedDevices: 0, + spareDevices: 0, + }, + ], + }, + }, + ] as any); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { resourceSnapshot: snapshot }); + }); + try { + await flushAsync(); + expect(result!.workloads()[0]?.canonicalResourceId).toBe(sampleResource.id); + expect(result!.workloads()[0]?.agentRaid).toMatchObject([ + { device: '/dev/md0', level: 'raid1', state: 'clean' }, + ]); + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index ab28ad2c6c..89e6eeb684 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -19,6 +19,7 @@ import { getPreferredResourceKubernetesContext, } from '@/utils/resourceIdentity'; import type { WorkloadGuest } from '@/types/workloads'; +import type { HostRAIDArray } from '@/types/api'; import type { Resource, ResourceActionReadiness, @@ -134,6 +135,7 @@ type APIResource = { agentVersion?: string; networkInterfaces?: APINetworkInterface[]; disks?: APIDiskInfo[]; + raid?: HostRAIDArray[]; }; docker?: { containerId?: string; @@ -526,6 +528,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { return { id: guestId, + canonicalResourceId: resource.id, alertResourceIds: buildAlertResourceIds(resource), metricsTarget: resource.metricsTarget, vmid: Number.isFinite(vmid) ? vmid : 0, @@ -579,6 +582,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { osVersion: resource.agent?.osVersion ?? resource.proxmox?.osVersion, agentVersion: resource.agent?.agentVersion, agentKind: resource.agent?.agentVersion ? 'pulse' : undefined, + agentRaid: resource.agent?.raid, networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces), networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0, networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0, diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index bf079ab599..12005d1459 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -1,4 +1,4 @@ -import type { VM, Container, DockerContainerUpdateStatus } from './api'; +import type { VM, Container, DockerContainerUpdateStatus, HostRAIDArray } from './api'; import type { ResourceActionReadiness, ResourceAvailabilityMeta, @@ -24,6 +24,8 @@ export type WorkloadGuest = (VM | Container) & { workloadType?: WorkloadType; /** Backend identities that may own active alerts for this canonical workload. */ alertResourceIds?: string[]; + /** Exact unified-resource ID; a Proxmox row ID can be a different VMID-based display key. */ + canonicalResourceId?: string; /** Backend-owned coordinates for metrics history; distinct from the stable workload row id. */ metricsTarget?: ResourceMetricsTarget; /** Canonical unified-resource health, kept separate from runtime power state in `status`. */ @@ -52,6 +54,8 @@ export type WorkloadGuest = (VM | Container) & { containerRuntime?: string; /** Identifies the producer of agentVersion instead of inferring it from VM type. */ agentKind?: 'pulse' | 'qemu-guest'; + /** RAID arrays reported by the linked Pulse agent, not Proxmox virtual disks. */ + agentRaid?: HostRAIDArray[]; updateStatus?: DockerContainerUpdateStatus; // Server-evaluated capability refusals from the unified resource. The // update button reads this to disable itself with the refusal reason