mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
Show the vCenter column only when several are connected (#2566)
With one vCenter, which most installs have, the vSphere hosts, health-signal and activity tables repeated its name on every row. The column told the user nothing and took room from the signal and activity text. On a phone, the health-signal table spent 66px on a clipped "Lab vC…". **What changes** - **When the column shows.** It appears only when the page's vSphere inventory spans more than one vCenter connection, and the header and cell drop together. - **How connections are counted.** From the page's whole vSphere inventory, never from the rows a search leaves. On Overview that includes the datastores and networks its health signals cover. A resource without a connection ID is matched to its connection by name or host, so one vCenter is never counted twice. - **Nothing lost.** Every row drawer still names the vCenter. - **Hosts table.** It now reads the connection name, as the other two tables do, instead of the raw vCenter host, which moves to the hover text. **Verification** - **One vCenter (the mock), live:** the column is gone from the Overview signals and hosts, the Health tab and Activity, and the Activity drawer still names the vCenter. - **Two vCenters:** I rewrote the resource API responses in the browser with the websocket held, which moved the Edge DC inventory to an "Edge vCenter" connection. The column returns with "Lab vCenter" and "Edge vCenter". - **Clip sweep against the pre-change run:** the health-signal table clips less at every narrow width. At 390px, Signal overflows by 92px instead of 116 and Started by 15px instead of 31. At 768px, Started overflows by 12px instead of 29. Activity gains about 50px for its sentences at 1440. - **Tests:** 1,811 frontend tests pass, including new single- and multi-vCenter tests for each table and for the connection count. - **Review:** a second reviewer found three issues: - Overview counting only hosts and VMs: fixed. - One vCenter counted twice when a resource had its name but not its ID: fixed. - An open drawer keeping the old column span if a vCenter is added or removed while it is open: left alone. It needs a change to the shared drawer-row component, which only re-measures on mount and resize, and the trigger is rare.
This commit is contained in:
@@ -2051,6 +2051,12 @@ consume it, and the column stays desktop-only on each of them; a platform
|
||||
table must not hand-roll its own reason cell. On vSphere Datastores and
|
||||
Networks the Hosts list shows only from a 72rem container, where it reads
|
||||
instead of truncating, and the drawer keeps it at every width.
|
||||
vSphere tables that carry a vCenter column (hosts, health signals, activity)
|
||||
render it only when more than one vCenter is connected: the page passes
|
||||
`singleVcenter` (or the hosts table derives it from its scope), the header and
|
||||
cell drop together, and the inline detail row's column span follows. A table
|
||||
must not hide a column by filtered rows, because a search that narrows to one
|
||||
vCenter would otherwise make the column vanish.
|
||||
Patrol's primary assessment strip is descriptive only; it must not render a
|
||||
Patrol-authored recommended next step, suggested prompt chips, or a secondary
|
||||
action band inside the assessment shell. If the same assessment opens
|
||||
|
||||
@@ -1586,6 +1586,17 @@ titles the event change with that message and falls back to the event class
|
||||
type row read it. Rows stored before that titling still carry the class as
|
||||
their title, so the page model prefers an event's `vmwareEventMessage` when it
|
||||
has one.
|
||||
The vSphere hosts, health-signal and activity tables show a vCenter column only
|
||||
when the page's vSphere inventory spans more than one vCenter connection.
|
||||
`isSingleVmwareVcenter` decides that from the page's whole vSphere resource set
|
||||
(on Overview, its hosts and VMs plus the datastores and networks its health
|
||||
signals cover), never from the rows a search or filter leaves, so narrowing a
|
||||
table does not drop the column. It counts connections by connection ID, and
|
||||
matches a resource without one to a known connection by name or vCenter host,
|
||||
so one vCenter is never counted twice. With
|
||||
one connection the name would repeat on every row; each row drawer still names
|
||||
it. The column reads the connection name, as the other vSphere tables do, with
|
||||
the vCenter host on hover.
|
||||
Primary platform navigation is also resource-evidence gated: runtime lenses such
|
||||
as Docker / Podman must be admitted from explicit Docker source scopes, Docker
|
||||
host or service resource types, or concrete runtime identity/inventory evidence
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "3054621cd81b34e6257b783ed2d7dbc21230bd74",
|
||||
"verified_at": "2026-10-06T10:53:36Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
|
||||
"frontend-modern/src/features/vmware/vmwarePageModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "18d0cddcbf32eb3cc62ac1b5f26cc80ed6efbd103c7b10c353ee9f4d90fc773c",
|
||||
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "fe2a97ce1cf30a3968ac0c99114ce1cf7763cf7ec43f49463f43d7ac4cf1823a",
|
||||
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "98b2ed6f01124396144c2ae2b6904f1371a320f095d8aff40cc08cf3f2a64b83",
|
||||
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "059dbff6cf2fb7f1191662d693569ea626fa8528bed08ed2523fcc74c5655cdf",
|
||||
"frontend-modern/src/features/vmware/vmwarePageModel.ts": "2992276a8fee32c361e0aa31fc02f35356eb8f06e5d936537b2b1bb7f3e6f9e1"
|
||||
},
|
||||
"routes": [
|
||||
"/vmware/overview",
|
||||
"/vmware/health",
|
||||
"/vmware/activity"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 600,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 1100
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 1100
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1100
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1100
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack on main af4e6a493c plus #2563 and this change (rebased onto main 3054621cd8, which adds #2563 itself and an LXC collection change touching no file here); the mock has one vCenter connection (Lab vCenter)",
|
||||
"One vCenter: the vCenter column is gone from the Overview health signals, the Overview hosts table, the Health tab and the Activity tab; the Activity row drawer still names Lab vCenter",
|
||||
"Two vCenters (resource API responses rewritten in the browser so the Edge DC hosts, datastores and networks belong to an 'Edge vCenter' connection, websocket held): the column returns on the health signals and hosts with Lab vCenter / Edge vCenter, hosts now reading the connection name with the vCenter host on hover",
|
||||
"Column sweep against the pre-change run: the health-signal table clips less at every narrow width (390: Signal +92 from +116 and Started +15 from +31 with the 66px vCenter column gone; 600: Signal 3 rows from 5; 768: Started +12 from +29); Activity gains about 50px for its sentences at 1440; the hosts table is unchanged apart from the dropped column"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded each tab per viewport and measured every visible header and cell for clipping",
|
||||
"Rewrote /api/resources responses via route interception with the websocket held to exercise the two-vCenter layout",
|
||||
"Opened an Activity row drawer and read its vCenter row"
|
||||
]
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
getVmwarePageTabSpecs,
|
||||
type VmwarePageModel,
|
||||
type VmwarePageTabId,
|
||||
isSingleVmwareVcenter,
|
||||
} from './vmwarePageModel';
|
||||
import { VsphereAlertsTable } from './VsphereAlertsTable';
|
||||
import { VsphereActivityTable } from './VsphereActivityTable';
|
||||
@@ -262,6 +263,16 @@ export function VmwarePageSurface() {
|
||||
},
|
||||
);
|
||||
const model = createMemo(() => buildVmwarePageModel(resources(), activityTimeline() ?? []));
|
||||
// Decided from the whole vSphere inventory so a filtered table keeps the
|
||||
// column when more than one vCenter is connected.
|
||||
// The Overview's own resources are hosts and VMs, but its signals also cover
|
||||
// datastores and networks, so it counts their connections too.
|
||||
const singleVcenter = createMemo(() =>
|
||||
isSingleVmwareVcenter([
|
||||
...model().resources,
|
||||
...(activeTab() === 'overview' ? healthResources.resources() : []),
|
||||
]),
|
||||
);
|
||||
const overviewIncidents = createMemo(() =>
|
||||
activeTab() === 'overview' ? buildVmwareIncidentRows(healthResources.resources()) : [],
|
||||
);
|
||||
@@ -352,6 +363,7 @@ export function VmwarePageSurface() {
|
||||
<div class="space-y-4">
|
||||
<Show when={overviewIncidents().length > 0}>
|
||||
<VsphereAlertsTable
|
||||
singleVcenter={singleVcenter()}
|
||||
incidents={overviewIncidents()}
|
||||
emptyIcon={vmwareIcon()}
|
||||
emptyTitle="No active vSphere health signals"
|
||||
@@ -393,6 +405,7 @@ export function VmwarePageSurface() {
|
||||
</Show>
|
||||
<Show when={activeTab() === 'health'}>
|
||||
<VsphereAlertsTable
|
||||
singleVcenter={singleVcenter()}
|
||||
incidents={model().incidents}
|
||||
emptyIcon={vmwareIcon()}
|
||||
emptyTitle="No active vSphere health signals"
|
||||
@@ -420,6 +433,7 @@ export function VmwarePageSurface() {
|
||||
}
|
||||
>
|
||||
<VsphereActivityTable
|
||||
singleVcenter={singleVcenter()}
|
||||
activity={model().activity}
|
||||
emptyIcon={vmwareIcon()}
|
||||
emptyTitle="No vSphere activity"
|
||||
|
||||
@@ -262,6 +262,9 @@ export const VsphereActivityTable: Component<{
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
showToolbar?: boolean;
|
||||
// One vCenter connection: its name would repeat on every row, so the
|
||||
// column hides (the row drawer still names it).
|
||||
singleVcenter?: boolean;
|
||||
}> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.activity,
|
||||
@@ -363,14 +366,16 @@ export const VsphereActivityTable: Component<{
|
||||
>
|
||||
Actor
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="vcenter"
|
||||
class="hidden lg:table-cell md:w-[10%]"
|
||||
>
|
||||
vCenter
|
||||
</PlatformSortableTableHead>
|
||||
<Show when={!props.singleVcenter}>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="vcenter"
|
||||
class="hidden lg:table-cell md:w-[10%]"
|
||||
>
|
||||
vCenter
|
||||
</PlatformSortableTableHead>
|
||||
</Show>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
@@ -460,21 +465,23 @@ export const VsphereActivityTable: Component<{
|
||||
{activity.actor || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden lg:table-cell`}
|
||||
>
|
||||
<span
|
||||
class="block truncate text-base-content"
|
||||
title={[
|
||||
meta()?.connectionName || meta()?.vcenterHost,
|
||||
meta()?.datacenterName || meta()?.clusterName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
<Show when={!props.singleVcenter}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden lg:table-cell`}
|
||||
>
|
||||
{meta()?.connectionName || meta()?.vcenterHost || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<span
|
||||
class="block truncate text-base-content"
|
||||
title={[
|
||||
meta()?.connectionName || meta()?.vcenterHost,
|
||||
meta()?.datacenterName || meta()?.clusterName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
>
|
||||
{meta()?.connectionName || meta()?.vcenterHost || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
@@ -489,7 +496,7 @@ export const VsphereActivityTable: Component<{
|
||||
<Show when={isExpanded()}>
|
||||
<InlineDetailTableRow
|
||||
cellId={detailRowId()}
|
||||
colspan={7}
|
||||
colspan={props.singleVcenter ? 6 : 7}
|
||||
data-inline-detail-for={activity.id}
|
||||
data-vsphere-activity-detail-row={activity.id}
|
||||
>
|
||||
|
||||
@@ -116,6 +116,9 @@ export const VsphereAlertsTable: Component<{
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
showToolbar?: boolean;
|
||||
// One vCenter connection: its name would repeat on every row, so the
|
||||
// column hides (the row drawer still names it).
|
||||
singleVcenter?: boolean;
|
||||
}> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.incidents,
|
||||
@@ -189,11 +192,13 @@ export const VsphereAlertsTable: Component<{
|
||||
>
|
||||
Signal
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[14%]`}
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="VC" full="vCenter" />
|
||||
</TableHead>
|
||||
<Show when={!props.singleVcenter}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[14%]`}
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="VC" full="vCenter" />
|
||||
</TableHead>
|
||||
</Show>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[12%]`}
|
||||
>
|
||||
@@ -270,19 +275,21 @@ export const VsphereAlertsTable: Component<{
|
||||
{incident.summary}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class={getPlatformTableCellClassForKind('text')}>
|
||||
<span
|
||||
class="block truncate text-base-content"
|
||||
title={[
|
||||
meta()?.connectionName || meta()?.vcenterHost,
|
||||
meta()?.datacenterName || meta()?.clusterName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
>
|
||||
{meta()?.connectionName || meta()?.vcenterHost || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<Show when={!props.singleVcenter}>
|
||||
<TableCell class={getPlatformTableCellClassForKind('text')}>
|
||||
<span
|
||||
class="block truncate text-base-content"
|
||||
title={[
|
||||
meta()?.connectionName || meta()?.vcenterHost,
|
||||
meta()?.datacenterName || meta()?.clusterName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
>
|
||||
{meta()?.connectionName || meta()?.vcenterHost || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
|
||||
>
|
||||
@@ -307,7 +314,7 @@ export const VsphereAlertsTable: Component<{
|
||||
<Show when={isExpanded()}>
|
||||
<InlineDetailTableRow
|
||||
cellId={detailRowId()}
|
||||
colspan={6}
|
||||
colspan={props.singleVcenter ? 5 : 6}
|
||||
data-inline-detail-for={incident.id}
|
||||
data-vsphere-alert-detail-row={incident.id}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
getVmwareResourceDisplayStatus,
|
||||
getVmwarePowerStateVariant,
|
||||
normalizeVmwarePowerStateToken,
|
||||
isSingleVmwareVcenter,
|
||||
vmwareVcenterLabel,
|
||||
} from './vmwarePageModel';
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import {
|
||||
@@ -128,7 +130,7 @@ const getVsphereHostSortValue = (
|
||||
case 'uptime':
|
||||
return typeof host.uptime === 'number' && host.uptime > 0 ? host.uptime : null;
|
||||
case 'vcenter':
|
||||
return asTrimmedString(meta?.vcenterHost) || null;
|
||||
return vmwareVcenterLabel(host) || null;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
@@ -157,6 +159,7 @@ export const VsphereHostsTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-host-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const singleVcenter = createMemo(() => isSingleVmwareVcenter(props.scope));
|
||||
|
||||
const vmCountByHost = createMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
@@ -313,14 +316,16 @@ export const VsphereHostsTable: Component<{
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="Up" full="Uptime" />
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="vcenter"
|
||||
class="hidden md:table-cell md:w-[9%]"
|
||||
>
|
||||
vCenter
|
||||
</PlatformSortableTableHead>
|
||||
<Show when={!singleVcenter()}>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="vcenter"
|
||||
class="hidden md:table-cell md:w-[9%]"
|
||||
>
|
||||
vCenter
|
||||
</PlatformSortableTableHead>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
@@ -332,7 +337,7 @@ export const VsphereHostsTable: Component<{
|
||||
const datacenter = () => asTrimmedString(meta()?.datacenterName) || '—';
|
||||
const cluster = () => asTrimmedString(meta()?.clusterName) || '—';
|
||||
const clusterServices = () => formatVmwareClusterServices(meta());
|
||||
const vcenter = () => asTrimmedString(meta()?.vcenterHost) || '—';
|
||||
const vcenter = () => vmwareVcenterLabel(host) || '—';
|
||||
// ESXi version from agent.osVersion (canonical projection of
|
||||
// HostSystem.config.product.fullName); uptime from the
|
||||
// sys.uptime.latest PerformanceManager counter routed onto
|
||||
@@ -500,19 +505,24 @@ export const VsphereHostsTable: Component<{
|
||||
>
|
||||
{uptimeLabel()}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden font-mono text-[11px] text-base-content md:table-cell`}
|
||||
>
|
||||
<span class="block truncate" title={vcenter()}>
|
||||
{vcenter()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<Show when={!singleVcenter()}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
<span
|
||||
class="block truncate"
|
||||
title={asTrimmedString(meta()?.vcenterHost) || vcenter()}
|
||||
>
|
||||
{vcenter()}
|
||||
</span>
|
||||
</TableCell>
|
||||
</Show>
|
||||
</TableRow>
|
||||
<PlatformResourceDetailTableRow
|
||||
resource={host}
|
||||
open={isExpanded()}
|
||||
detailRowId={detailRowId()}
|
||||
colSpan={11}
|
||||
colSpan={singleVcenter() ? 10 : 11}
|
||||
resolveResourceLabel={resolveResourceLabel}
|
||||
onClose={() => drawer.close(host)}
|
||||
/>
|
||||
|
||||
@@ -195,4 +195,20 @@ describe('VsphereActivityTable', () => {
|
||||
expect(detail.getByText('VmPoweredOnEvent')).toBeInTheDocument();
|
||||
expect(detail.queryByText('Message')).not.toBeInTheDocument();
|
||||
});
|
||||
it('drops the vCenter column when one vCenter is connected', () => {
|
||||
render(() => (
|
||||
<VsphereActivityTable
|
||||
activity={buildVmwareActivityRows([makeVm()])}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No activity"
|
||||
emptyDescription="No activity"
|
||||
showToolbar={false}
|
||||
singleVcenter
|
||||
/>
|
||||
));
|
||||
expect(
|
||||
within(screen.getByRole('table')).queryByRole('columnheader', { name: /vCenter/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('lab-vcenter')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,4 +151,24 @@ describe('VsphereAlertsTable', () => {
|
||||
'false',
|
||||
);
|
||||
});
|
||||
it('drops the vCenter column when one vCenter is connected and keeps it in the drawer', async () => {
|
||||
render(() => (
|
||||
<VsphereAlertsTable
|
||||
incidents={buildVmwareIncidentRows([makeHost()])}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No signals"
|
||||
emptyDescription="No signals"
|
||||
showToolbar={false}
|
||||
singleVcenter
|
||||
/>
|
||||
));
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
expect(within(table).queryByRole('columnheader', { name: /vCenter/ })).not.toBeInTheDocument();
|
||||
const row = document.querySelector('[data-vsphere-alert-row]') as HTMLElement;
|
||||
expect(within(row).queryByText('lab-vcenter')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(row);
|
||||
expect(screen.getByText('lab-vcenter')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,4 +161,42 @@ describe('VsphereHostsTable', () => {
|
||||
expect(container.querySelector('[data-vsphere-host-row="host-healthy"]')).toBeNull();
|
||||
expect(screen.getByText('1 of 2 hosts')).toBeInTheDocument();
|
||||
});
|
||||
it('shows the vCenter column by connection name only when several vCenters are connected', () => {
|
||||
const lab = makeHost({ id: 'host-1' });
|
||||
lab.vmware = { ...lab.vmware!, connectionId: 'vc-lab', connectionName: 'Lab vCenter' };
|
||||
const edge = makeHost({ id: 'host-2' });
|
||||
edge.vmware = {
|
||||
...edge.vmware!,
|
||||
connectionId: 'vc-edge',
|
||||
connectionName: 'Edge vCenter',
|
||||
vcenterHost: 'vcsa-edge.lab.local',
|
||||
};
|
||||
|
||||
const single = render(() => (
|
||||
<VsphereHostsTable
|
||||
hosts={[lab]}
|
||||
scope={[lab]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No hosts"
|
||||
emptyDescription="No hosts"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
expect(screen.queryByRole('columnheader', { name: /vCenter/ })).not.toBeInTheDocument();
|
||||
single.unmount();
|
||||
|
||||
render(() => (
|
||||
<VsphereHostsTable
|
||||
hosts={[lab, edge]}
|
||||
scope={[lab, edge]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No hosts"
|
||||
emptyDescription="No hosts"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('columnheader', { name: /vCenter/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('Edge vCenter')).toHaveAttribute('title', 'vcsa-edge.lab.local');
|
||||
expect(screen.getByText('Lab vCenter')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
filterVmwareVirtualMachines,
|
||||
getVmwarePageTabSpecs,
|
||||
getVmwareResourceDisplayStatus,
|
||||
isSingleVmwareVcenter,
|
||||
mapVmwareActivityStateBucket,
|
||||
mapVmwareDatastoreStatus,
|
||||
mapVmwareIncidentSeverity,
|
||||
@@ -543,4 +544,34 @@ describe('vmwarePageModel', () => {
|
||||
filterVmwareActivity(rows, 'administrator', 'events').map((row) => row.nativeId),
|
||||
).toEqual(['event-501']);
|
||||
});
|
||||
it('counts vCenter connections across the whole inventory', () => {
|
||||
const at = (vmware: Record<string, string>) =>
|
||||
({ id: JSON.stringify(vmware), vmware }) as never;
|
||||
expect(isSingleVmwareVcenter([])).toBe(true);
|
||||
expect(
|
||||
isSingleVmwareVcenter([
|
||||
at({ connectionId: 'vc-1', connectionName: 'Lab vCenter' }),
|
||||
at({ connectionId: 'vc-1', vcenterHost: 'vcsa.lab.local' }),
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSingleVmwareVcenter([at({ connectionId: 'vc-1' }), at({ connectionId: 'vc-2' })]),
|
||||
).toBe(false);
|
||||
// A resource without the connection ID still matches its connection by
|
||||
// name or host, so one vCenter is not counted twice.
|
||||
expect(
|
||||
isSingleVmwareVcenter([
|
||||
at({ connectionId: 'vc-1', connectionName: 'Lab', vcenterHost: 'vcsa.lab.local' }),
|
||||
at({ connectionName: 'Lab', vcenterHost: 'vcsa.lab.local' }),
|
||||
at({ vcenterHost: 'vcsa.lab.local' }),
|
||||
]),
|
||||
).toBe(true);
|
||||
// Without connection ids, the name or host still tells connections apart.
|
||||
expect(
|
||||
isSingleVmwareVcenter([
|
||||
at({ vcenterHost: 'vcsa.lab.local' }),
|
||||
at({ vcenterHost: 'vcsa-edge.lab.local' }),
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +169,35 @@ export const getVmwarePageTabSpecs = (
|
||||
const normalize = (value: unknown): string =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
|
||||
const vmwareConnectionFields = (resource: Resource) => ({
|
||||
id: resource.vmware?.connectionId?.trim().toLowerCase() || '',
|
||||
aliases: [resource.vmware?.connectionName, resource.vmware?.vcenterHost]
|
||||
.map((value) => value?.trim().toLowerCase() || '')
|
||||
.filter(Boolean),
|
||||
});
|
||||
|
||||
// Whether the page's vSphere inventory comes from one vCenter connection, which
|
||||
// makes a vCenter column repeat on every row. Decided from the whole inventory,
|
||||
// never from filtered rows, so a search that narrows to one vCenter does not
|
||||
// drop the column. A resource without a connection ID is matched to a known
|
||||
// connection by its name or host, so one vCenter is never counted twice.
|
||||
export const isSingleVmwareVcenter = (resources: readonly Resource[]): boolean => {
|
||||
const fields = resources.map(vmwareConnectionFields);
|
||||
const idByAlias = new Map<string, string>();
|
||||
for (const { id, aliases } of fields) {
|
||||
if (id) aliases.forEach((alias) => idByAlias.set(alias, id));
|
||||
}
|
||||
const connections = new Set<string>();
|
||||
for (const { id, aliases } of fields) {
|
||||
const key = id || aliases.map((alias) => idByAlias.get(alias)).find(Boolean) || aliases[0];
|
||||
if (key) connections.add(key);
|
||||
}
|
||||
return connections.size <= 1;
|
||||
};
|
||||
|
||||
export const vmwareVcenterLabel = (resource: Resource): string =>
|
||||
resource.vmware?.connectionName?.trim() || resource.vmware?.vcenterHost?.trim() || '';
|
||||
|
||||
export const getVmwareResourceDisplayStatus = (resource: Resource): string =>
|
||||
hasImpairedResourceSource(resource, 'vmware-vsphere') ? 'degraded' : resource.status;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user