Surface VMware drawer context on shared resources

This commit is contained in:
rcourtman
2026-03-30 22:58:19 +01:00
parent 16b9e079a6
commit 7529ddcdd5
10 changed files with 631 additions and 22 deletions
@@ -61,23 +61,24 @@ cross-source deduplication.
39. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
40. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
41. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts`
42. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
43. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
44. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
45. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
46. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
47. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
48. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`
49. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
50. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
51. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
52. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
53. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
54. `frontend-modern/src/features/infrastructure/infrastructurePageModel.ts`
55. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
56. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
57. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
58. `frontend-modern/src/utils/agentResources.ts`
42. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts`
43. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
44. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
45. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
46. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
47. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
48. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
49. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`
50. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
51. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
52. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
53. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
54. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
55. `frontend-modern/src/features/infrastructure/infrastructurePageModel.ts`
56. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
57. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
58. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
59. `frontend-modern/src/utils/agentResources.ts`
59. `frontend-modern/src/utils/canonicalResourceTypes.ts`
60. `frontend-modern/src/utils/resourceBadgePresentation.ts`
61. `frontend-modern/src/utils/resourceChangePresentation.ts`
@@ -1421,6 +1422,15 @@ Shared infrastructure consumers such as the unified resource table and detail
drawer must present that owned metadata through shared helpers instead of
reconstructing privacy posture from display names, source types, or other
incidental runtime hints.
That same shared-consumer boundary now also owns VMware phase-1 detail
presentation. `frontend-modern/src/components/Infrastructure/`
`resourceDetailDrawerVmwareModel.ts`,
`ResourceDetailDrawerOverviewTab.tsx`, `ResourceDetailDrawerDebugTab.tsx`,
`resourceDetailDrawerIdentityModel.ts`, and
`useResourceDetailDrawerDerivedState.ts` must surface VMware read-only
placement, signal, and snapshot context through the canonical resource drawer
and debug/source sections rather than introducing a VMware-only detail route,
drawer tab, or provider-local investigation shell.
That same infrastructure consumer boundary also owns route-backed source
selection continuity. `frontend-modern/src/features/infrastructure/`
must keep a route-owned canonical source such as `truenas` present in the
@@ -53,6 +53,17 @@ interface ResourceDetailDrawerOverviewTabProps {
const hasMetadataEntries = (value?: Record<string, unknown> | null): boolean =>
Boolean(value && Object.keys(value).length > 0);
const vmwareRowToneClass = (tone?: 'default' | 'accent' | 'warning'): string => {
switch (tone) {
case 'accent':
return 'text-sky-700 dark:text-sky-300';
case 'warning':
return 'text-amber-700 dark:text-amber-300';
default:
return 'text-base-content';
}
};
const timelineKindOptions: Array<{ label: string; value: ResourceChangeKind | '' }> = [
{ label: 'All kinds', value: '' },
...RESOURCE_CHANGE_KIND_ORDER.map((kind) => ({
@@ -510,6 +521,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<Show
when={
drawer.hasServiceDetails() ||
drawer.hasVMwareDetails() ||
drawer.hasHostDetails() ||
drawer.hasAccessContext() ||
drawer.hasInvestigationContext()
@@ -1118,6 +1130,47 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</SupportDisclosure>
</Show>
<Show when={drawer.hasVMwareDetails()}>
<SupportDisclosure
title="vSphere"
summary={drawer.vmwareDetailsSummary()}
expanded={drawer.showVMwareDetails()}
onToggle={() => drawer.setShowVMwareDetails((value) => !value)}
showLabel="Show vSphere"
hideLabel="Hide vSphere"
class="h-full"
contentClass="mt-3 space-y-3"
dataTestId="resource-vmware-details-section"
>
<div class="space-y-3">
<For each={drawer.vmwareDetailSections()}>
{(section) => (
<div class="rounded border border-sky-200 bg-sky-50 p-3 dark:border-sky-700 dark:bg-sky-900">
<div class="mb-2 text-[10px] font-medium uppercase tracking-wide text-sky-700 dark:text-sky-300">
{section.label}
</div>
<div class="space-y-1.5 text-[11px]">
<For each={section.rows}>
{(row) => (
<div class="flex items-center justify-between gap-2">
<span class="text-muted">{row.label}</span>
<span
class={`max-w-[60%] truncate text-right font-medium ${vmwareRowToneClass(row.tone)}`}
title={row.value}
>
{row.value}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
</SupportDisclosure>
</Show>
<Show when={drawer.hasHostDetails()}>
<SupportDisclosure
title="Host"
@@ -11,6 +11,7 @@ import resourceDetailDrawerDiscoveryModelSource from '@/components/Infrastructur
import resourceDetailDrawerIdentityModelSource from '@/components/Infrastructure/resourceDetailDrawerIdentityModel.ts?raw';
import resourceDetailDrawerOperationalModelSource from '@/components/Infrastructure/resourceDetailDrawerOperationalModel.ts?raw';
import resourceDetailDrawerServiceModelSource from '@/components/Infrastructure/resourceDetailDrawerServiceModel.ts?raw';
import resourceDetailDrawerVmwareModelSource from '@/components/Infrastructure/resourceDetailDrawerVmwareModel.ts?raw';
import resourceDetailDrawerDockerActionsStateSource from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts?raw';
import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw';
import type { Resource } from '@/types/resource';
@@ -155,6 +156,9 @@ describe('ResourceDetailDrawer change history section', () => {
expect(resourceDetailDrawerDerivedStateSource).toContain(
"from './resourceDetailDrawerIdentityModel'",
);
expect(resourceDetailDrawerDerivedStateSource).toContain(
"from './resourceDetailDrawerVmwareModel'",
);
expect(resourceDetailDrawerDiscoveryModelSource).toContain('export const toDiscoveryConfig');
expect(resourceDetailDrawerIdentityModelSource).toContain(
'export const buildResourceIdentityView',
@@ -165,6 +169,12 @@ describe('ResourceDetailDrawer change history section', () => {
expect(resourceDetailDrawerIdentityModelSource).toContain(
'export const buildResourceDebugBundle',
);
expect(resourceDetailDrawerVmwareModelSource).toContain(
'export const buildVMwareDetailSections',
);
expect(resourceDetailDrawerVmwareModelSource).toContain(
'export const buildVMwareDetailsSummary',
);
expect(resourceDetailDrawerDerivedStateSource).not.toContain('buildWorkloadsHref');
expect(resourceDetailDrawerDerivedStateSource).not.toContain('buildServiceDetailLinks');
expect(resourceDetailDrawerDerivedStateSource).not.toContain('const supportedBadge =');
@@ -252,6 +252,88 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
expect(getByText('eth0')).toBeInTheDocument();
});
it('surfaces VMware read-only placement and signal context on the shared drawer path', async () => {
const resource = baseResource({
type: 'vm',
name: 'app-01',
displayName: 'App 01',
platformId: 'vc-1:vm:vm-201',
platformType: 'vmware-vsphere',
sourceType: 'api',
platformData: {
sources: ['vmware'],
vmware: {
connectionName: 'Lab VC',
vcenterHost: 'vc.lab.local',
entityType: 'VirtualMachine',
overallStatus: 'green',
powerState: 'poweredOn',
datacenterName: 'Lab DC',
clusterName: 'Compute Cluster',
resourcePoolName: 'Production',
runtimeHostName: 'esxi-01.lab.local',
datastoreNames: ['shared-vsan'],
guestOsFamily: 'ubuntu64Guest',
guestHostname: 'app-01.lab.local',
guestIpAddresses: ['192.0.2.50'],
activeAlarmCount: 1,
activeAlarmSummary: 'Host fan degraded',
recentTaskCount: 1,
recentTaskSummary: 'Create snapshot (success)',
snapshotCount: 2,
},
},
vmware: {
connectionName: 'Lab VC',
vcenterHost: 'vc.lab.local',
entityType: 'VirtualMachine',
overallStatus: 'green',
powerState: 'poweredOn',
datacenterName: 'Lab DC',
clusterName: 'Compute Cluster',
resourcePoolName: 'Production',
runtimeHostName: 'esxi-01.lab.local',
datastoreNames: ['shared-vsan'],
guestOsFamily: 'ubuntu64Guest',
guestHostname: 'app-01.lab.local',
guestIpAddresses: ['192.0.2.50'],
activeAlarmCount: 1,
activeAlarmSummary: 'Host fan degraded',
recentTaskCount: 1,
recentTaskSummary: 'Create snapshot (success)',
snapshotCount: 2,
},
});
const { getByRole, getByText, getByTestId, queryByText } = render(() => (
<ResourceDetailDrawer resource={resource} />
));
expect(getByTestId('resource-vmware-details-section')).toBeInTheDocument();
expect(getByText('Lab VC · Read-only vCenter context · 2 snapshots · 1 alarm · 1 task')).toBeInTheDocument();
expect(queryByText('Compute Cluster')).toBeNull();
expect(queryByText('Create snapshot (success)')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show vSphere' }));
await waitFor(() => {
expect(getByText('Placement')).toBeInTheDocument();
});
const section = getByTestId('resource-vmware-details-section');
expect(within(section).getByText('State')).toBeInTheDocument();
expect(within(section).getByText('Placement')).toBeInTheDocument();
expect(within(section).getByText('Guest')).toBeInTheDocument();
expect(within(section).getByText('Signals')).toBeInTheDocument();
expect(within(section).getByText('vc.lab.local')).toBeInTheDocument();
expect(within(section).getByText('Compute Cluster')).toBeInTheDocument();
expect(within(section).getByText('esxi-01.lab.local')).toBeInTheDocument();
expect(within(section).getByText('ubuntu64Guest')).toBeInTheDocument();
expect(within(section).getByText(/Create snapshot \(success\)/)).toBeInTheDocument();
expect(within(section).getByText(/Host fan degraded/)).toBeInTheDocument();
expect(within(section).getByText('2 snapshots')).toBeInTheDocument();
});
it('keeps source provenance in the header when no source health issue is present', () => {
const resource = baseResource({
sourceType: 'api',
@@ -79,6 +79,7 @@ describe('resourceDetailDrawerIdentityModel', () => {
const platformData = {
agent: { hostname: 'host-1' },
pbs: { instanceId: 'pbs-1' },
vmware: { connectionName: 'Lab VC' },
metrics: { cpu: 42 },
matchResults: { source: 'match-results' },
matches: { source: 'matches' },
@@ -87,6 +88,7 @@ describe('resourceDetailDrawerIdentityModel', () => {
expect(buildSourceSections(platformData).map((section) => section.id)).toEqual([
'agent',
'pbs',
'vmware',
'metrics',
]);
expect(buildIdentityMatchInfo(platformData)).toEqual({ source: 'match-results' });
@@ -108,11 +110,12 @@ describe('resourceDetailDrawerIdentityModel', () => {
resource,
platformData: {
proxmox: { nodeName: 'pve-1' },
docker: { runtime: 'docker' },
},
sourceStatus: {
proxmox: { status: 'online' },
},
docker: { runtime: 'docker' },
vmware: { connectionName: 'Lab VC' },
},
sourceStatus: {
proxmox: { status: 'online' },
},
identityMatchInfo: { matchedBy: 'hostname' },
}),
).toEqual({
@@ -131,6 +134,7 @@ describe('resourceDetailDrawerIdentityModel', () => {
pbs: undefined,
pmg: undefined,
kubernetes: undefined,
vmware: { connectionName: 'Lab VC' },
metrics: undefined,
},
});
@@ -71,6 +71,7 @@ export const buildSourceSections = (
{ id: 'pbs', label: 'PBS', payload: platformData.pbs },
{ id: 'pmg', label: 'PMG', payload: platformData.pmg },
{ id: 'kubernetes', label: 'Kubernetes', payload: platformData.kubernetes },
{ id: 'vmware', label: 'vSphere', payload: platformData.vmware },
{ id: 'metrics', label: 'Metrics', payload: platformData.metrics },
].filter((section) => section.payload !== undefined);
};
@@ -101,6 +102,7 @@ export const buildResourceDebugBundle = (options: {
pbs: options.platformData?.pbs,
pmg: options.platformData?.pmg,
kubernetes: options.platformData?.kubernetes,
vmware: options.platformData?.vmware,
metrics: options.platformData?.metrics,
},
});
@@ -0,0 +1,232 @@
import type { ResourceType, ResourceVMwareMeta } from '@/types/resource';
export type ResourceDetailDrawerVMwareRowTone = 'default' | 'accent' | 'warning';
export type ResourceDetailDrawerVMwareRow = {
label: string;
value: string;
tone?: ResourceDetailDrawerVMwareRowTone;
};
export type ResourceDetailDrawerVMwareSection = {
id: 'state' | 'placement' | 'guest' | 'signals';
label: string;
rows: ResourceDetailDrawerVMwareRow[];
};
const asTrimmedString = (value?: string | null): string => (value || '').trim();
const formatCount = (count: number, label: string): string =>
`${count} ${label}${count === 1 ? '' : 's'}`;
const formatBoolLabel = (value?: boolean): string => {
if (value === undefined) return '';
return value ? 'Yes' : 'No';
};
const vmwareEntityLabel = (entityType?: string): string => {
const normalized = asTrimmedString(entityType).toLowerCase();
switch (normalized) {
case 'host':
case 'hostsystem':
return 'Host';
case 'vm':
case 'virtualmachine':
return 'VM';
case 'datastore':
return 'Datastore';
default:
return asTrimmedString(entityType);
}
};
const buildSignalValue = (count: number | undefined, label: string, summary?: string): string => {
const parts: string[] = [];
if (typeof count === 'number') {
parts.push(formatCount(Math.max(0, count), label));
}
const trimmedSummary = asTrimmedString(summary);
if (trimmedSummary) {
parts.push(trimmedSummary);
}
return parts.join(' · ');
};
const hasRows = (rows: ResourceDetailDrawerVMwareRow[]): boolean => rows.length > 0;
export const buildVMwareDetailsSummary = (
resourceType: ResourceType,
vmware?: ResourceVMwareMeta,
): string | null => {
if (!vmware) return null;
const parts: string[] = [];
const connection = asTrimmedString(vmware.connectionName) || asTrimmedString(vmware.vcenterHost);
if (connection) {
parts.push(connection);
}
parts.push('Read-only vCenter context');
if (resourceType === 'vm' && typeof vmware.snapshotCount === 'number') {
parts.push(formatCount(Math.max(0, vmware.snapshotCount), 'snapshot'));
}
if ((vmware.activeAlarmCount ?? 0) > 0) {
parts.push(formatCount(vmware.activeAlarmCount ?? 0, 'alarm'));
}
if ((vmware.recentTaskCount ?? 0) > 0) {
parts.push(formatCount(vmware.recentTaskCount ?? 0, 'task'));
}
return parts.join(' · ');
};
export const buildVMwareDetailSections = (
resourceType: ResourceType,
vmware?: ResourceVMwareMeta,
): ResourceDetailDrawerVMwareSection[] => {
if (!vmware) {
return [];
}
const stateRows: ResourceDetailDrawerVMwareRow[] = [
{
label: 'Connection',
value: asTrimmedString(vmware.connectionName),
},
{
label: 'vCenter',
value: asTrimmedString(vmware.vcenterHost),
},
{
label: 'Entity',
value: vmwareEntityLabel(vmware.entityType),
},
{
label: 'Overall status',
value: asTrimmedString(vmware.overallStatus),
tone:
asTrimmedString(vmware.overallStatus).toLowerCase() === 'red'
? 'warning'
: asTrimmedString(vmware.overallStatus)
? 'accent'
: 'default',
},
{
label: 'Power',
value: asTrimmedString(vmware.powerState),
},
{
label: 'Connection state',
value: asTrimmedString(vmware.connectionState),
},
{
label: 'Datastore type',
value: asTrimmedString(vmware.datastoreType),
},
{
label: 'Accessible',
value: formatBoolLabel(vmware.datastoreAccessible),
tone: vmware.datastoreAccessible === false ? 'warning' : 'default',
},
{
label: 'Shared access',
value: formatBoolLabel(vmware.multipleHostAccess),
},
{
label: 'Maintenance',
value: asTrimmedString(vmware.maintenanceMode),
tone: asTrimmedString(vmware.maintenanceMode) ? 'warning' : 'default',
},
].filter((row) => row.value);
const placementRows: ResourceDetailDrawerVMwareRow[] = [
{
label: 'Datacenter',
value: asTrimmedString(vmware.datacenterName),
},
{
label: 'Cluster',
value: asTrimmedString(vmware.clusterName),
},
{
label: 'Compute resource',
value: asTrimmedString(vmware.computeResourceName),
},
{
label: 'Folder',
value: asTrimmedString(vmware.folderName),
},
{
label: 'Resource pool',
value: asTrimmedString(vmware.resourcePoolName),
},
{
label: 'Runtime host',
value: asTrimmedString(vmware.runtimeHostName),
},
{
label: 'Datastores',
value: (vmware.datastoreNames ?? []).filter(Boolean).join(', '),
},
].filter((row) => row.value);
const guestRows: ResourceDetailDrawerVMwareRow[] = [
{
label: 'Host UUID',
value: asTrimmedString(vmware.hostUuid),
},
{
label: 'Instance UUID',
value: asTrimmedString(vmware.instanceUuid),
},
{
label: 'BIOS UUID',
value: asTrimmedString(vmware.biosUuid),
},
{
label: 'Guest OS',
value: asTrimmedString(vmware.guestOsFamily),
},
{
label: 'Guest hostname',
value: asTrimmedString(vmware.guestHostname),
},
{
label: 'Guest IPs',
value: (vmware.guestIpAddresses ?? []).filter(Boolean).join(', '),
},
{
label: 'Datastore URL',
value: asTrimmedString(vmware.datastoreUrl),
},
].filter((row) => row.value);
const signalRows: ResourceDetailDrawerVMwareRow[] = [
{
label: 'Alarms',
value: buildSignalValue(vmware.activeAlarmCount, 'alarm', vmware.activeAlarmSummary),
tone: (vmware.activeAlarmCount ?? 0) > 0 ? 'warning' : 'default',
},
{
label: 'Tasks',
value: buildSignalValue(vmware.recentTaskCount, 'task', vmware.recentTaskSummary),
tone: (vmware.recentTaskCount ?? 0) > 0 ? 'accent' : 'default',
},
{
label: 'Snapshots',
value:
resourceType === 'vm' || typeof vmware.snapshotCount === 'number'
? formatCount(Math.max(0, vmware.snapshotCount ?? 0), 'snapshot')
: '',
},
].filter((row) => row.value);
const sections: ResourceDetailDrawerVMwareSection[] = [
{ id: 'state', label: 'State', rows: stateRows },
{ id: 'placement', label: 'Placement', rows: placementRows },
{ id: 'guest', label: 'Guest', rows: guestRows },
{ id: 'signals', label: 'Signals', rows: signalRows },
];
return sections.filter((section) => hasRows(section.rows));
};
@@ -1,6 +1,7 @@
import { createMemo, type Accessor } from 'solid-js';
import type { Resource } from '@/types/resource';
import { requiresGovernedResourceDisplay } from '@/types/resource';
import type { ResourceVMwareMeta } from '@/types/resource';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import { getAgentStatusIndicator } from '@/utils/status';
import {
@@ -57,6 +58,10 @@ import {
buildResourceIdentityView,
buildSourceSections,
} from './resourceDetailDrawerIdentityModel';
import {
buildVMwareDetailsSummary,
buildVMwareDetailSections,
} from './resourceDetailDrawerVmwareModel';
type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
@@ -114,6 +119,9 @@ export const useResourceDetailDrawerDerivedState = (
const kubernetesMeta = createMemo(
() => resource.kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined),
);
const vmwareData = createMemo(
() => resource.vmware ?? (platformData()?.vmware as ResourceVMwareMeta | undefined),
);
const kubernetesCapabilityBadges = createMemo(() =>
buildKubernetesCapabilityBadges(kubernetesMeta()?.metricCapabilities),
);
@@ -250,6 +258,13 @@ export const useResourceDetailDrawerDerivedState = (
pmg: pmgData(),
});
});
const vmwareDetailSections = createMemo(() =>
buildVMwareDetailSections(resource.type, vmwareData()),
);
const hasVMwareDetails = createMemo(() => vmwareDetailSections().length > 0);
const vmwareDetailsSummary = createMemo(() =>
buildVMwareDetailsSummary(resource.type, vmwareData()),
);
const relatedLinks = createMemo(() => buildRelatedLinks(resource, displayName()));
const accessSummary = createMemo(() =>
@@ -317,6 +332,7 @@ export const useResourceDetailDrawerDerivedState = (
hasGovernanceData,
agentMeta,
kubernetesMeta,
vmwareData,
kubernetesCapabilityBadges,
proxmoxNode,
agentInfo,
@@ -365,6 +381,9 @@ export const useResourceDetailDrawerDerivedState = (
hostDetailSummary,
hasServiceDetails,
serviceDetailsSummary,
hasVMwareDetails,
vmwareDetailsSummary,
vmwareDetailSections,
relatedLinks,
hasRuntimeOperationalContext,
sourceSections,
@@ -28,6 +28,7 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false);
const [showHostDetails, setShowHostDetails] = createSignal(false);
const [showServiceDetails, setShowServiceDetails] = createSignal(false);
const [showVMwareDetails, setShowVMwareDetails] = createSignal(false);
const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false);
const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false);
const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal('');
@@ -102,6 +103,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
setShowHostDetails,
showServiceDetails,
setShowServiceDetails,
showVMwareDetails,
setShowVMwareDetails,
showPbsJobDetail,
setShowPbsJobDetail,
showPmgMailFlowDetail,
@@ -0,0 +1,194 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test as base } from '@playwright/test';
import { createAuthenticatedStorageState } from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCREENSHOT_PATH = '/tmp/vmware-resource-detail-drawer.png';
const RESOURCE_ID = 'vc-1:vm:vm-201';
const RESOURCE_ID_ENCODED = encodeURIComponent(RESOURCE_ID);
type WorkerFixtures = {
authStorageStatePath: string;
};
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`vmware-resource-detail-drawer-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
}, { scope: 'worker' }],
});
test.describe('VMware resource detail drawer', () => {
test.setTimeout(180_000);
test('surfaces VMware read-only context through the shared drawer path', async ({ page }) => {
let unexpectedVmwareApiCall: string | null = null;
await page.route('**/api/vmware/**', async (route) => {
unexpectedVmwareApiCall = route.request().url();
await route.abort();
});
await page.route('**/api/resources**', async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname === '/api/resources') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{
id: RESOURCE_ID,
type: 'vm',
name: 'app-01.lab.local',
displayName: 'App 01',
platformId: RESOURCE_ID,
platformType: 'vmware-vsphere',
sourceType: 'api',
sources: ['vmware-vsphere'],
status: 'running',
lastSeen: '2026-03-30T18:25:00Z',
canonicalIdentity: {
displayName: 'App 01',
hostname: 'app-01.lab.local',
platformId: RESOURCE_ID,
},
vmware: {
connectionId: 'vc-1',
connectionName: 'Lab VC',
vcenterHost: 'vc.lab.local',
managedObjectId: 'vm-201',
entityType: 'VirtualMachine',
overallStatus: 'green',
powerState: 'poweredOn',
datacenterName: 'Lab DC',
clusterName: 'Compute Cluster',
resourcePoolName: 'Production',
runtimeHostName: 'esxi-01.lab.local',
datastoreNames: ['shared-vsan'],
guestOsFamily: 'ubuntu64Guest',
guestHostname: 'app-01.lab.local',
guestIpAddresses: ['192.0.2.50'],
activeAlarmCount: 1,
activeAlarmSummary: 'Host fan degraded',
recentTaskCount: 1,
recentTaskSummary: 'Create snapshot (success)',
snapshotCount: 2,
},
platformData: {
sources: ['vmware-vsphere'],
},
},
],
meta: {
page: 1,
limit: 100,
total: 1,
totalPages: 1,
},
}),
});
return;
}
if (requestUrl.pathname === `/api/resources/${RESOURCE_ID_ENCODED}/facets`) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
capabilities: [],
relationships: [],
recentChanges: [],
counts: {
recentChanges: 0,
},
}),
});
return;
}
await route.continue();
});
await page.route('**/api/ai/intelligence**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
resource_id: RESOURCE_ID,
resource_name: 'App 01',
resource_type: 'vm',
health: {
score: 88,
grade: 'B',
trend: 'stable',
factors: [],
prediction: 'VMware placement and signal context is available on the shared drawer.',
},
dependencies: [],
dependents: [],
correlations: [],
recent_changes: [],
note_count: 0,
}),
});
});
await page.goto(
`/infrastructure?source=vmware-vsphere&resource=${encodeURIComponent(RESOURCE_ID)}`,
{
waitUntil: 'domcontentloaded',
},
);
const vmwareSection = page.getByTestId('resource-vmware-details-section');
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
await expect(page.locator('#infra-source-filter')).toHaveValue('vmware-vsphere');
await expect(
page.locator('div[title="App 01"]').filter({ hasText: 'App 01' }).first(),
).toBeVisible();
await expect(vmwareSection).toBeVisible();
await expect(vmwareSection).toContainText(
'Lab VC · Read-only vCenter context · 2 snapshots · 1 alarm · 1 task',
);
await vmwareSection.getByRole('button', { name: 'Show vSphere' }).click();
await expect(vmwareSection.getByText('State', { exact: true })).toBeVisible();
await expect(vmwareSection.getByText('Placement', { exact: true })).toBeVisible();
await expect(vmwareSection.getByText('Guest', { exact: true })).toBeVisible();
await expect(vmwareSection.getByText('Signals', { exact: true })).toBeVisible();
await expect(vmwareSection.getByText('vc.lab.local')).toBeVisible();
await expect(vmwareSection.getByText('Compute Cluster')).toBeVisible();
await expect(vmwareSection.getByText('esxi-01.lab.local')).toBeVisible();
await expect(vmwareSection.getByText('ubuntu64Guest')).toBeVisible();
await expect(vmwareSection.getByText('Create snapshot (success)')).toBeVisible();
await expect(vmwareSection.getByText('Host fan degraded')).toBeVisible();
await expect(vmwareSection.getByText('2 snapshots', { exact: true })).toBeVisible();
expect(unexpectedVmwareApiCall).toBeNull();
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
});
});