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