diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a34b4420b..54d018885 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 544041ab7..851a37148 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -53,6 +53,17 @@ interface ResourceDetailDrawerOverviewTabProps { const hasMetadataEntries = (value?: Record | 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 + + drawer.setShowVMwareDetails((value) => !value)} + showLabel="Show vSphere" + hideLabel="Hide vSphere" + class="h-full" + contentClass="mt-3 space-y-3" + dataTestId="resource-vmware-details-section" + > +
+ + {(section) => ( +
+
+ {section.label} +
+
+ + {(row) => ( +
+ {row.label} + + {row.value} + +
+ )} +
+
+
+ )} +
+
+
+
+ { 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 ='); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index ff6442f79..669269770 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -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(() => ( + + )); + + 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', diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts index c9e505df5..3d5b6f8a7 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts @@ -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, }, }); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts index 6b16c6412..8a6df9c09 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts @@ -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, }, }); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts new file mode 100644 index 000000000..c9a48db77 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts @@ -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)); +}; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 4b38b68db..705c5ab62 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -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, diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts index d097b05c1..a394bfec6 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -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, diff --git a/tests/integration/tests/39-vmware-resource-detail-drawer.spec.ts b/tests/integration/tests/39-vmware-resource-detail-drawer.spec.ts new file mode 100644 index 000000000..6809decca --- /dev/null +++ b/tests/integration/tests/39-vmware-resource-detail-drawer.spec.ts @@ -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 }); + }); +});