Show linked guest disks and RAID in VM details

Scope physical-disk reads to the opened guest, preserve agent RAID in the drawer, and prove the desktop and narrow interaction states with the performance contract updated in this same commit.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-26 18:37:32 +01:00
parent 27036a2445
commit 7ed49d2f0a
12 changed files with 473 additions and 41 deletions
@@ -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
@@ -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; });
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/guest-storage-2263.tsx"></script>
</body>
</html>
@@ -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<Mode>('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(() => (
<main class="mx-auto max-w-3xl space-y-4 p-4">
<h1 class="text-lg font-semibold">Guest storage browser fixture</h1>
<nav class="flex gap-2" aria-label="Fixture states">
<button type="button" onClick={() => setMode('linked')}>Linked guest</button>
<button type="button" onClick={() => setMode('none')}>No agent</button>
<button type="button" onClick={() => setMode('error')}>Unavailable query</button>
</nav>
<Show when={mode() === 'linked'}>
<GuestDrawerOverview {...overviewProps} guest={linkedGuest as any} />
</Show>
<Show when={mode() === 'none'}>
<GuestDrawerOverview {...overviewProps} guest={baseGuest as any} />
</Show>
<Show when={mode() === 'error'}>
<GuestDrawerOverview {...overviewProps} guest={errorGuest as any} />
</Show>
</main>
), document.getElementById('root')!);
+23 -40
View File
@@ -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."
}
@@ -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()]}
/>
<Show
when={
props.guest.agentKind === 'pulse' ||
props.guest.agentVersion ||
props.guest.agentRaid?.length
}
>
<GuestPhysicalDisks parentId={props.guest.canonicalResourceId ?? props.guest.id} />
</Show>
<Show when={props.guest.agentRaid?.length}>
<RaidCard arrays={props.guest.agentRaid} title="Guest RAID" />
</Show>
<div class="space-y-3">
<Show when={props.guest.availability || props.guest.availabilityChecks?.length}>
@@ -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 (
<details
class="rounded border border-border p-2"
onToggle={(event) => setExpanded(event.currentTarget.open)}
data-testid="guest-physical-disk"
>
<summary class="flex cursor-pointer flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span class="font-semibold text-base-content">{label()}</span>
<Show when={data().devPath}>
<span class="font-mono text-muted">{data().devPath}</span>
</Show>
<span class={health().tone} title={health().summary}>
{health().label}
</span>
<Show when={data().temperature > 0}>
<span class="text-muted">{formatTemperature(data().temperature)}</span>
</Show>
<Show when={data().size > 0}>
<span class="text-muted">{formatBytes(data().size)}</span>
</Show>
</summary>
<Show when={expanded()}>
<div class="mt-2 border-t border-border pt-2">
<DiskDetail disk={props.disk} nodes={[]} />
</div>
</Show>
</details>
);
};
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 (
<>
<Show when={source.error()}>
<p class="text-xs text-muted" role="status">
Physical disk details are unavailable.
</p>
</Show>
<Show when={disks().length > 0}>
<InfoCardFrame data-testid="guest-physical-disks">
<h3 class="mb-2 text-[11px] font-medium uppercase tracking-wide text-base-content">
Physical Disks &amp; SMART ({disks().length})
</h3>
<div class="space-y-2">
<For each={disks()}>{(disk) => <GuestPhysicalDiskRow disk={disk} />}</For>
</div>
</InfoCardFrame>
</Show>
</>
);
};
@@ -55,6 +55,34 @@ const props: ComponentProps<typeof GuestDrawerOverview> = {
describe('GuestDrawerOverview filesystem labels', () => {
afterEach(cleanup);
it('shows linked-agent RAID arrays beside the guest storage evidence', () => {
render(() => (
<GuestDrawerOverview
{...props}
guest={{
...props.guest,
agentRaid: [
{
device: '/dev/md0',
name: 'data',
level: 'raid1',
state: 'clean',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
},
],
}}
/>
));
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(() => <GuestDrawerOverview {...props} />);
for (const path of paths) {
@@ -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(() => <GuestPhysicalDisks parentId="vm-resource-101" />);
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(() => <GuestPhysicalDisks parentId="vm-resource-102" />);
expect(screen.queryByTestId('guest-physical-disks')).toBeNull();
});
it('encodes the parent value and never widens an empty parent to all disks', () => {
render(() => <GuestPhysicalDisks parentId="vm&101" />);
expect(queryState.query).toBe('type=physical_disk&parent=vm%26101');
cleanup();
render(() => <GuestPhysicalDisks parentId=" " />);
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(() => <GuestPhysicalDisks parentId="vm-resource-101" />);
expect(screen.getByRole('status')).toHaveTextContent('Physical disk details are unavailable.');
});
});
@@ -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<UseWorkloadsModule['useWorkloads']> | 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();
@@ -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,
+5 -1
View File
@@ -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