mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Surface VMware drawer context on shared resources
This commit is contained in:
@@ -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"
|
||||
|
||||
+10
@@ -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 =');
|
||||
|
||||
+82
@@ -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',
|
||||
|
||||
+9
-5
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user