From 4884052bc5577c2c42f76b8ac482dc1e512531eb Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:03:54 +0100 Subject: [PATCH] Show each identifier once in the drawer's Identity card A Machines agent drawer listed agent:host-linux-1 as its Primary ID, its Discovery target and its Metrics Target, then again in Aliases next to the hostname and Platform ID it had just shown. Every platform's drawer shares this card, and a vSphere datastore repeated its ID the same way. Discovery and Metrics Target rows now appear only when they name something not already on screen: the header's name, the rows above, or the Platform ID row. Aliases leave out those same values. Names the drawer never shows do not count, so a distinct target still appears. A shown Platform ID counts as identity data, so filtering cannot leave a false "No identity metadata yet". Search and alias lookups keep the full lists; only the card's display changes. --- .../internal/subsystems/unified-resources.md | 8 ++- .../25ae222eae885bd3.json | 37 ++++++++++++ ...urceDetailDrawer.identity-runtime.test.tsx | 10 ++-- .../resourceDetailDrawerIdentityModel.test.ts | 59 +++++++++++++++++++ .../resourceDetailDrawerIdentityModel.ts | 20 ++++++- .../utils/__tests__/resourceIdentity.test.ts | 27 ++++++++- frontend-modern/src/utils/resourceIdentity.ts | 14 ++++- 7 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 frontend-modern/browser-verification/25ae222eae885bd3.json diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b91304e4d5..e1fb444dd3 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4331,7 +4331,13 @@ must render from that shared projection instead of rescanning raw job arrays or inventing local PBS status heuristics, `resourceDetailDrawerIdentityModel.ts` owns the pure identity-card, discovery-summary, source-debug, and debug-bundle derivations that feed the -overview and debug drawer surfaces, +overview and debug drawer surfaces (the `Identity` card shows each identifier +once: Discovery and Metrics Target rows appear only when they name something +not already on screen, meaning the header's `getPreferredInfrastructureDisplayName`, +the rows above, or the Platform ID row, and Aliases omits the same displayed +values; candidate names the drawer never shows do not count, a shown Platform +ID counts as identity data for the empty state, and a Machines agent drawer +used to list one ID four times), `useResourceDetailDrawerDockerActionsState.ts` owns Docker action runtime, and the overview/debug render-heavy surfaces live in dedicated drawer-local owners instead of staying inline in the shell. diff --git a/frontend-modern/browser-verification/25ae222eae885bd3.json b/frontend-modern/browser-verification/25ae222eae885bd3.json new file mode 100644 index 0000000000..16c60c167a --- /dev/null +++ b/frontend-modern/browser-verification/25ae222eae885bd3.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "base_sha": "27051deb279b8dc42a82922f14ebdddc7e8fbfcc", + "verified_at": "2026-10-06T14:58:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts", + "frontend-modern/src/utils/resourceIdentity.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts": "902911e665585a831c072675c85609bc1e6015e71db0deb79877767d9b454892", + "frontend-modern/src/utils/resourceIdentity.ts": "c76d4279e7caca8b96b1a52e4c166f791264217730d631f47b8de3cedf411c32" + }, + "routes": [ + "/standalone/machines", + "/vmware/storage" + ], + "viewports": [ + { + "width": 1440, + "height": 1300 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack on main c4db7a3fba plus this change (rebased onto 27051deb27, whose node-alert dedup touches no file here); opened a Machines agent drawer (Apollo-114) and a vSphere datastore drawer (archive-tier)", + "Before (same stack with this change stashed): Machines Identity read Hostname apollo-114 | Primary ID agent:host-linux-1 | Discovery agent:host-linux-1 | Metrics Target agent:host-linux-1 | Platform ID host-linux-1 | IP Addresses | Aliases agent:host-linux-1, host-linux-1, apollo-114, agent-acdfdee2953587fb; vSphere repeated the Primary ID as Metrics Target and in Aliases, plus the hostname and Platform ID", + "After: Machines reads Hostname apollo-114 | Primary ID agent:host-linux-1 | Platform ID host-linux-1 | IP Addresses | Aliases agent-acdfdee2953587fb; vSphere reads Hostname | Primary ID | Platform ID | Tags | Aliases vc-mock-1:datastore:datastore-202, datastore-202. Every identifier appears once", + "Phone (390): both drawers' Identity cards read the same single values, stacked label/value rows, nothing clipped" + ], + "interactions": [ + "Opened each drawer and read its Identity card, before and after" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index 651f482880..ec278c40f8 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -799,7 +799,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(getByText('Primary ID').parentElement?.textContent).toContain('node:homelab-delly'); }); - it('shows canonical metrics target identity for docker-backed host resources', () => { + it('shows the canonical metrics target once, as the Primary ID', () => { const resource = baseResource({ id: 'hash-docker-resource', type: 'docker-host', @@ -824,12 +824,14 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { }, }); - const { container, getByText, getAllByText } = render(() => ( + const { container, getByText, getAllByText, queryByText } = render(() => ( )); - expect(getByText('Metrics Target')).toBeInTheDocument(); - expect(getAllByText('docker-host:docker-host-1').length).toBeGreaterThan(1); + // The metrics target is the Primary ID, so neither its row nor an alias + // repeats it. + expect(queryByText('Metrics Target')).toBeNull(); + expect(getAllByText('docker-host:docker-host-1')).toHaveLength(1); expect(getByText('Aliases')).toBeInTheDocument(); expect(getAllByText('docker-host-1').length).toBeGreaterThan(0); expect(container.querySelector('.text-\\[11px\\].text-muted.truncate')).toBeNull(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts index 6c5a640564..a5754f8b01 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerIdentityModel.test.ts @@ -25,6 +25,65 @@ const baseResource = (overrides: Partial = {}): Resource => ({ }); describe('resourceDetailDrawerIdentityModel', () => { + it('filters aliases against the name the drawer header actually shows', () => { + const identityView = buildResourceIdentityView( + baseResource({ + displayName: '', + name: 'legacy', + platformId: 'legacy', + canonicalIdentity: { + displayName: 'Canonical label', + aliases: ['legacy', 'Canonical label'], + }, + } as Partial), + ); + // The header reads "Canonical label"; "legacy" is shown nowhere else. + expect(identityView.identityAliasValues).toContain('legacy'); + expect(identityView.identityAliasValues).not.toContain('Canonical label'); + }); + + it('counts a shown Platform ID as identity data', () => { + const identityView = buildResourceIdentityView( + baseResource({ + name: 'vm-101', + displayName: 'VM 101', + platformId: 'qemu-101', + canonicalIdentity: { aliases: ['qemu-101'] }, + } as Partial), + ); + expect(identityView.identityAliasValues).toEqual([]); + expect(identityView.identityCardHasRichData).toBe(true); + }); + + it('lists only aliases the drawer does not already show', () => { + const identityView = buildResourceIdentityView( + baseResource({ + id: 'agent-acdfdee2953587fb', + type: 'agent', + name: 'Apollo-114', + displayName: 'Apollo-114', + platformId: 'host-linux-1', + identity: { hostname: 'apollo-114' }, + discoveryTarget: { + resourceType: 'agent', + agentId: 'host-linux-1', + resourceId: 'host-linux-1', + }, + metricsTarget: { resourceType: 'agent', resourceId: 'host-linux-1' }, + canonicalIdentity: { + aliases: ['agent:host-linux-1', 'host-linux-1', 'apollo-114', 'agent-acdfdee2953587fb'], + }, + } as Partial), + ); + + // Hostname, Primary ID and the Platform ID row already show these. + expect(identityView.primaryIdentityRows.map((row) => row.label)).toEqual([ + 'Hostname', + 'Primary ID', + ]); + expect(identityView.identityAliasValues).toEqual(['agent-acdfdee2953587fb']); + }); + it('builds canonical identity card state including alias collapse policy', () => { const identityView = buildResourceIdentityView( baseResource({ diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts index f8f58ea1f4..4321af68bd 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts @@ -1,7 +1,9 @@ import type { Resource } from '@/types/resource'; import { getPrimaryResourceIdentityRows, + getPreferredInfrastructureDisplayName, getResourceIdentityAliases, + shouldShowResourcePlatformId, type ResourceIdentityRow, } from '@/utils/resourceIdentity'; import { formatIdentifierLabel } from '@/utils/textPresentation'; @@ -26,9 +28,24 @@ export type ResourceDetailDrawerIdentityView = { const ALIAS_COLLAPSE_THRESHOLD = 4; export const buildResourceIdentityView = (resource: Resource): ResourceDetailDrawerIdentityView => { - const identityAliasValues = getResourceIdentityAliases(resource); const identityIpValues = resource.identity?.ips ?? []; const primaryIdentityRows = getPrimaryResourceIdentityRows(resource); + // The drawer header already shows the resource's display name, and the rows + // above show these IDs and (when it differs from every name) the platform + // ID, so Aliases lists only the other names the resource goes by. + const platformIdShown = shouldShowResourcePlatformId(resource); + const shown = new Set( + [ + getPreferredInfrastructureDisplayName(resource), + platformIdShown ? resource.platformId : undefined, + ...primaryIdentityRows.map((row) => row.value), + ] + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)), + ); + const identityAliasValues = getResourceIdentityAliases(resource).filter( + (alias) => !shown.has(alias.trim().toLowerCase()), + ); return { identityAliasValues, @@ -36,6 +53,7 @@ export const buildResourceIdentityView = (resource: Resource): ResourceDetailDra primaryIdentityRows, identityCardHasRichData: primaryIdentityRows.length > 0 || + platformIdShown || identityIpValues.length > 0 || (resource.tags?.length || 0) > 0 || identityAliasValues.length > 0, diff --git a/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts b/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts index b8c2d7a6e0..a6430ac888 100644 --- a/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts @@ -187,7 +187,32 @@ describe('resourceIdentity', () => { { label: 'Cluster', value: 'cluster-a' }, { label: 'Parent', value: 'parent-1' }, { label: 'Discovery', value: 'agent:agent-1' }, - { label: 'Metrics Target', value: 'docker-host:docker-host-1' }, + // The metrics target is the Primary ID shown above, so it is not repeated. + ]); + }); + + it('keeps a discovery target that matches only a name the drawer never shows', () => { + const rows = getPrimaryResourceIdentityRows( + makeResource({ + displayName: 'Friendly host', + canonicalIdentity: { displayName: 'agent:other' }, + discoveryTarget: { resourceType: 'agent', agentId: 'other', resourceId: 'other' }, + metricsTarget: { resourceType: 'node', resourceId: 'self' }, + } as Parameters[0]), + ); + expect(rows.find((row) => row.label === 'Discovery')?.value).toBe('agent:other'); + }); + + it('shows an identity once when discovery and metrics targets name the resource itself', () => { + const rows = getPrimaryResourceIdentityRows( + makeResource({ + identity: { hostname: 'tower.local' }, + discoveryTarget: { resourceType: 'agent', agentId: 'agent-1', resourceId: 'agent-1' }, + metricsTarget: { resourceType: 'agent', resourceId: 'agent-1' }, + }), + ); + expect(rows.filter((row) => row.value === 'agent:agent-1').map((row) => row.label)).toEqual([ + 'Primary ID', ]); }); diff --git a/frontend-modern/src/utils/resourceIdentity.ts b/frontend-modern/src/utils/resourceIdentity.ts index dcb1da81b5..49ddad0f20 100644 --- a/frontend-modern/src/utils/resourceIdentity.ts +++ b/frontend-modern/src/utils/resourceIdentity.ts @@ -289,11 +289,21 @@ export const getPrimaryResourceIdentityRows = (resource: Resource): ResourceIden rows.push({ label: 'Parent', value: resource.parentId }); } + // Discovery and metrics targets usually point at the resource itself, so + // they show only when they name something not already on screen: the + // drawer header's name, the rows above, or the Platform ID row. + const shownIdentity = (value: string): boolean => + [ + getPreferredInfrastructureDisplayName(resource), + shouldShowResourcePlatformId(resource) ? asTrimmedString(resource.platformId) : undefined, + ...rows.map((row) => row.value), + ].some((shown) => Boolean(shown) && shown!.toLowerCase() === value.toLowerCase()); + const discoveryIdentity = formatIdentityTarget( resource.discoveryTarget?.resourceType, resource.discoveryTarget?.resourceId, ); - if (discoveryIdentity) { + if (discoveryIdentity && !shownIdentity(discoveryIdentity)) { rows.push({ label: 'Discovery', value: discoveryIdentity }); } @@ -301,7 +311,7 @@ export const getPrimaryResourceIdentityRows = (resource: Resource): ResourceIden resource.metricsTarget?.resourceType, resource.metricsTarget?.resourceId, ); - if (metricsIdentity) { + if (metricsIdentity && !shownIdentity(metricsIdentity)) { rows.push({ label: 'Metrics Target', value: metricsIdentity }); }