From a51c736768fa50ff5a7c244d9f282c9f9819d59b Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:07:13 +0100 Subject: [PATCH] Show the vCenter column only when several are connected 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...". The column now shows only when the page's vSphere inventory spans more than one connection. That is decided from the whole inventory, not from the rows a search leaves, and a resource without a connection ID is matched to its connection by name or host, so one vCenter is never counted twice. Each row drawer still names the vCenter. The hosts table now reads the connection name, as the other two do, with the vCenter host on hover. --- .../subsystems/frontend-primitives.md | 6 ++ .../internal/subsystems/unified-resources.md | 11 ++++ .../f779d5131159073e.json | 62 +++++++++++++++++++ .../src/features/vmware/VmwarePageSurface.tsx | 14 +++++ .../features/vmware/VsphereActivityTable.tsx | 53 +++++++++------- .../features/vmware/VsphereAlertsTable.tsx | 45 ++++++++------ .../src/features/vmware/VsphereHostsTable.tsx | 46 ++++++++------ .../__tests__/VsphereActivityTable.test.tsx | 16 +++++ .../__tests__/VsphereAlertsTable.test.tsx | 20 ++++++ .../__tests__/VsphereHostsTable.test.tsx | 38 ++++++++++++ .../vmware/__tests__/vmwarePageModel.test.ts | 31 ++++++++++ .../src/features/vmware/vmwarePageModel.ts | 29 +++++++++ 12 files changed, 311 insertions(+), 60 deletions(-) create mode 100644 frontend-modern/browser-verification/f779d5131159073e.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 269e8b4e05..43d9dca2e9 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4494318107..a22df85cf1 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification/f779d5131159073e.json b/frontend-modern/browser-verification/f779d5131159073e.json new file mode 100644 index 0000000000..4667ba2bbc --- /dev/null +++ b/frontend-modern/browser-verification/f779d5131159073e.json @@ -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" + ] +} diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index a82e52d926..e3aeeda59e 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -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() {
0}> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.activity, @@ -363,14 +366,16 @@ export const VsphereActivityTable: Component<{ > Actor - + + + - + + {meta()?.connectionName || meta()?.vcenterHost || '-'} + + + @@ -489,7 +496,7 @@ export const VsphereActivityTable: Component<{ diff --git a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx index b3e5434ba5..cb0dfe473b 100644 --- a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx @@ -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 - - - + + + + + - - - {meta()?.connectionName || meta()?.vcenterHost || '-'} - - + + + + {meta()?.connectionName || meta()?.vcenterHost || '-'} + + + - + + + drawer.close(host)} /> diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx index 77a6f15af0..20f33fbf57 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx @@ -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(() => ( + } + 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(); + }); }); diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx index 389dd05d76..d62aa6f3f7 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx @@ -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(() => ( + } + 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(); + }); }); diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereHostsTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereHostsTable.test.tsx index aa2a781fbd..2cee99416f 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereHostsTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereHostsTable.test.tsx @@ -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(() => ( + } + emptyTitle="No hosts" + emptyDescription="No hosts" + showToolbar={false} + /> + )); + expect(screen.queryByRole('columnheader', { name: /vCenter/ })).not.toBeInTheDocument(); + single.unmount(); + + render(() => ( + } + 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(); + }); }); diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts index 72ea8920ee..d24251b948 100644 --- a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts +++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts @@ -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) => + ({ 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); + }); }); diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index 5c1d0d142c..1656e710e9 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -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(); + for (const { id, aliases } of fields) { + if (id) aliases.forEach((alias) => idByAlias.set(alias, id)); + } + const connections = new Set(); + 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;