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:
rcourtman
2026-10-06 12:45:27 +01:00
committed by GitHub
12 changed files with 311 additions and 60 deletions
@@ -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;