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 }); }