mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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')!);
|
||||
@@ -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 & SMART ({disks().length})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<For each={disks()}>{(disk) => <GuestPhysicalDiskRow disk={disk} />}</For>
|
||||
</div>
|
||||
</InfoCardFrame>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+28
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user