Deduplicate versioned infrastructure system badges

This commit is contained in:
rcourtman 2026-05-08 15:42:18 +01:00
parent 825f230dec
commit 4d5d77cf79
9 changed files with 123 additions and 21 deletions

View file

@ -666,6 +666,10 @@ prompt explain the same operator-facing priority.
`pveVersion` or a Pulse Agent report whose OS identity resolves to Unraid or
Proxmox VE. They must omit the version rather than showing unrelated
collector OS versions, such as Debian 12, beside an API-backed PVE badge.
System title metadata must apply the same identity rule: once the primary
system badge names a platform with its version, source/method context may
still add collection labels such as Pulse Agent, but it must not repeat the
same platform again as an unversioned source badge.
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
10. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
11. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.

View file

@ -812,6 +812,10 @@ adding extra Agent badge width or sorting primarily by the telemetry method.
When a row is only known through an agent or container runtime, the table must
prefer reported OS/appliance identity before falling back to Docker/runtime
capability labels.
When the displayed system badge already includes the platform version, row title
metadata must not spend extra badge/title budget repeating that same platform as
an unversioned source; it may keep non-duplicate collection context such as
Pulse Agent.
That derived workload owner now also routes grouped row windowing through
`frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts`, which
owns row-window thresholds, overscan behavior, reveal-index clamping, and

View file

@ -156,6 +156,9 @@ cross-source deduplication.
infrastructure platform first, while full merged-source detail remains
available for tooltips, accessibility metadata, and routing. Agent telemetry
is collection-method detail when a provider/API platform is also present.
When the primary system identity includes a platform version, table metadata
must suppress an otherwise duplicate unversioned platform source badge while
preserving non-duplicate collection context such as Pulse Agent.
Infrastructure table presentation controls must describe the table mode
rather than a platform-specific resource concept: the grouped/flat toggle
uses operator-facing `Grouped` and `List` wording with a `Group by`

View file

@ -26,8 +26,8 @@ import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
dedupeResourceBadges,
getInfrastructureSystemIdentityBadges,
getInfrastructureSystemTitleBadges,
getPlatformBadge,
getSourceBadge,
getUnifiedSourceBadges,
@ -322,7 +322,7 @@ export const UnifiedResourceHostTableCard: Component<UnifiedResourceHostTableCar
getInfrastructureSystemIdentityBadges(resource),
);
const systemTitleBadges = createMemo(() =>
dedupeResourceBadges([...systemBadges(), ...sourceBadges()]),
getInfrastructureSystemTitleBadges(systemBadges(), sourceBadges()),
);
const policyBadges = createMemo(() =>
getResourcePolicyTableBadges(resource.policy),

View file

@ -16,8 +16,8 @@ import {
TableRow,
} from '@/components/shared/Table';
import {
dedupeResourceBadges,
getInfrastructureSystemIdentityBadges,
getInfrastructureSystemTitleBadges,
getPlatformBadge,
getSourceBadge,
getUnifiedSourceBadges,
@ -123,7 +123,7 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
getInfrastructureSystemIdentityBadges(resource),
);
const systemTitleBadges = createMemo(() =>
dedupeResourceBadges([...systemBadges(), ...sourceBadges()]),
getInfrastructureSystemTitleBadges(systemBadges(), sourceBadges()),
);
const healthClass = createMemo(
() =>

View file

@ -16,8 +16,8 @@ import {
TableRow,
} from '@/components/shared/Table';
import {
dedupeResourceBadges,
getInfrastructureSystemIdentityBadges,
getInfrastructureSystemTitleBadges,
getPlatformBadge,
getSourceBadge,
getUnifiedSourceBadges,
@ -137,7 +137,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
getInfrastructureSystemIdentityBadges(resource),
);
const systemTitleBadges = createMemo(() =>
dedupeResourceBadges([...systemBadges(), ...sourceBadges()]),
getInfrastructureSystemTitleBadges(systemBadges(), sourceBadges()),
);
const healthClass = createMemo(
() =>

View file

@ -217,11 +217,49 @@ describe('UnifiedResourceTable performance contract', () => {
await waitFor(() => {
expect(getByText('System')).toBeInTheDocument();
expect(getByText('Unraid')).toBeInTheDocument();
expect(getByText('Unraid 7.1.0')).toBeInTheDocument();
});
expect(queryByText('Docker')).toBeNull();
});
it('keeps versioned platform identity from repeating as a plain source in system titles', async () => {
const resources = [
makeResource(1, {
type: 'agent',
displayName: 'pi',
platformType: 'proxmox-pve',
sourceType: 'hybrid',
sources: ['proxmox', 'agent'],
platformData: {
sources: ['proxmox', 'agent'],
proxmox: {
pveVersion: '',
},
agent: {
platform: 'raspbian',
osName: 'Proxmox VE',
osVersion: '8.3.3',
},
},
}),
];
const { container, getByText } = render(() => (
<UnifiedResourceTable
resources={resources}
expandedResourceId={null}
onExpandedResourceChange={vi.fn()}
groupingMode="flat"
/>
));
await waitFor(() => {
expect(getByText('PVE 8.3.3')).toBeInTheDocument();
});
expect(container.querySelector('[aria-label="System: PVE 8.3.3, Agent"]')).toBeTruthy();
expect(container.querySelector('[aria-label="System: PVE 8.3.3, PVE, Agent"]')).toBeNull();
});
it('renders compact canonical health reasons without replacing host identity', async () => {
const resources = [
makeResource(1, {

View file

@ -4,6 +4,7 @@ import {
getInfrastructurePlatformBadges,
getInfrastructureSystemIdentityBadges,
getInfrastructureSystemIdentitySortLabel,
getInfrastructureSystemTitleBadges,
getPlatformBadge,
getSourceBadge,
getTypeBadge,
@ -137,9 +138,9 @@ describe('resourceBadgePresentation', () => {
} as Resource['storage'],
});
expect(getInfrastructureSystemIdentityBadges(unraidStorage).map((badge) => badge.label)).toEqual(
['Unraid'],
);
expect(
getInfrastructureSystemIdentityBadges(unraidStorage).map((badge) => badge.label),
).toEqual(['Unraid']);
expect(getInfrastructureSystemIdentitySortLabel(unraidStorage)).toBe('Unraid');
const pbsDatastore = makeResource({
@ -154,9 +155,9 @@ describe('resourceBadgePresentation', () => {
} as Resource['storage'],
});
expect(getInfrastructureSystemIdentityBadges(pbsDatastore).map((badge) => badge.label)).toEqual([
'PBS',
]);
expect(getInfrastructureSystemIdentityBadges(pbsDatastore).map((badge) => badge.label)).toEqual(
['PBS'],
);
});
it('does not let stale platform sources override explicit agent host profiles', () => {
@ -301,6 +302,35 @@ describe('resourceBadgePresentation', () => {
);
});
it('does not repeat the unversioned platform source after a versioned system identity', () => {
const agentDiscoveredPve = makeResource({
type: 'agent',
platformType: 'proxmox-pve',
sourceType: 'hybrid',
sources: ['proxmox', 'agent'],
platformData: {
sources: ['proxmox', 'agent'],
agent: {
platform: 'raspbian',
osName: 'Proxmox VE',
osVersion: '8.3.3',
},
proxmox: {
pveVersion: '',
},
},
});
const systemBadges = getInfrastructureSystemIdentityBadges(agentDiscoveredPve);
const sourceBadges = getUnifiedSourceBadges(agentDiscoveredPve.sources);
expect(systemBadges.map((badge) => badge.label)).toEqual(['PVE 8.3.3']);
expect(sourceBadges.map((badge) => badge.label)).toEqual(['PVE', 'Agent']);
expect(
getInfrastructureSystemTitleBadges(systemBadges, sourceBadges).map((badge) => badge.label),
).toEqual(['PVE 8.3.3', 'Agent']);
});
it('keeps top-level platform facets ahead of collection method identity', () => {
const resource = makeResource({
platformType: 'agent',

View file

@ -144,6 +144,20 @@ const withBadgeVersion = (badge: ResourceBadge, version: string): ResourceBadge
};
};
const VERSIONED_BADGE_SUFFIX_PATTERN = /\s+v?\d+(?:[._-]\d+)*(?:[-+~:][0-9a-z][0-9a-z._:+~-]*)?$/i;
const normalizeBadgeIdentityLabel = (value: string): string =>
value.trim().toLowerCase().replace(VERSIONED_BADGE_SUFFIX_PATTERN, '').trim();
const badgeIdentityLabels = (badge: ResourceBadge): string[] =>
Array.from(
new Set(
[badge.label, badge.title]
.map((value) => (value ? normalizeBadgeIdentityLabel(value) : ''))
.filter(Boolean),
),
);
const normalizeUnifiedSourceKeys = (sources?: string[] | null): KnownSourcePlatform[] => {
if (!sources || sources.length === 0) return [];
const normalized = sources
@ -483,13 +497,7 @@ const getSystemSourceVersion = (
}
case 'vmware-vsphere': {
const vmware = getFacetRecord(resource, platformData, 'vmware');
return getRecordVersion(
vmware,
'version',
'productVersion',
'apiVersion',
'vcenterVersion',
);
return getRecordVersion(vmware, 'version', 'productVersion', 'apiVersion', 'vcenterVersion');
}
case 'kubernetes': {
const kubernetes = getFacetRecord(resource, platformData, 'kubernetes');
@ -500,7 +508,9 @@ const getSystemSourceVersion = (
return getRecordVersion(docker, 'runtimeVersion', 'dockerVersion', 'version');
}
case 'unraid':
return getAgentPlatformVersion(resource, source) || getRecordVersion(platformData, 'osVersion');
return (
getAgentPlatformVersion(resource, source) || getRecordVersion(platformData, 'osVersion')
);
default:
return (
getRecordVersion(sourceRecord, 'version', 'osVersion', 'productVersion') ||
@ -627,6 +637,19 @@ export function dedupeResourceBadges(
});
}
export function getInfrastructureSystemTitleBadges(
systemBadges: ResourceBadge[],
sourceBadges: ResourceBadge[],
): ResourceBadge[] {
const systemIdentities = new Set(systemBadges.flatMap(badgeIdentityLabels));
return dedupeResourceBadges([
...systemBadges,
...sourceBadges.filter((badge) =>
badgeIdentityLabels(badge).every((identity) => !systemIdentities.has(identity)),
),
]);
}
export function getContainerRuntimeBadge(
platformType?: PlatformType,
platformData?: Record<string, unknown> | null,