From bb7b607ca51cb84fe588aa165afee874166a7d4c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 28 Apr 2026 14:43:01 +0100 Subject: [PATCH] Clarify infrastructure system identity badges --- .../internal/PLATFORM_SUPPORT_MANIFEST.json | 4 +- .../subsystems/frontend-primitives.md | 14 +- .../subsystems/performance-and-scalability.md | 17 +- .../internal/subsystems/unified-resources.md | 23 ++- ...dashboardWorkloadFilterConfigModel.test.ts | 4 +- .../dashboardWorkloadRouteModel.test.ts | 2 +- .../UnifiedResourceHostTableCard.tsx | 14 +- .../UnifiedResourcePBSTableSection.tsx | 14 +- .../UnifiedResourcePMGTableSection.tsx | 14 +- .../UnifiedResourceSourceBadgeCell.tsx | 20 +-- .../__tests__/ReportMergeModal.test.tsx | 4 +- ...esourceTable.performance.contract.test.tsx | 45 ++++- .../__tests__/infrastructureSelectors.test.ts | 24 ++- .../__tests__/resourceBadges.test.ts | 2 +- .../unifiedResourceTableStateModel.test.ts | 6 +- .../Infrastructure/infrastructureSelectors.ts | 3 +- .../Infrastructure/resourceBadges.ts | 2 + .../resourceDetailDrawerIdentityModel.ts | 2 +- .../unifiedResourceTableStateModel.ts | 6 +- .../__tests__/sourcePlatformBadges.test.ts | 10 +- .../resourceBadgePresentation.test.ts | 98 +++++++++++ .../utils/__tests__/sourcePlatforms.test.ts | 2 +- .../platformSupportManifest.generated.ts | 12 +- .../src/utils/resourceBadgePresentation.ts | 164 +++++++++++++++++- 24 files changed, 413 insertions(+), 93 deletions(-) diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index 7be0f8e18..c3bf11cac 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -66,11 +66,11 @@ "assistant_read": "supported", "assistant_control": "supported" }, - "ui_label": "Containers", + "ui_label": "Docker", "ui_tone": "bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400", "aliases": [], "display_tokens": [ - "Containers", + "Container runtime", "Docker" ], "storage_family": "container" diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1798d815d..1c569b3c6 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -414,12 +414,14 @@ work extends shared components instead of creating new local variants. return. Frontend infrastructure feature surfaces inherit that same source/platform vocabulary. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx` - must label the operator-facing resource filter and table column as - `Platform`, not `Source`, while lower-level unified-resource contracts - preserve merged-source detail for tooltips, accessibility metadata, and - routing. Collection methods such as Pulse Agent may appear as option or - detail labels, but they must not become the primary top-level platform - wording when a provider/API platform owns the resource. + must keep the operator-facing resource filter on `Platform`, not `Source`, + while the infrastructure table labels its primary identity column as + `System`. Lower-level unified-resource contracts preserve merged-source + detail for tooltips, accessibility metadata, and routing. Collection methods + such as Pulse Agent and runtime capabilities such as Docker may appear as + option or detail labels, but they must not become the primary top-level + system wording when a provider/API platform or reported host OS/appliance + identity better explains what the operator is looking at. 6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state. 7. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches. 8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 5b3939f1f..5d78e057e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -677,13 +677,16 @@ matching, and row titles on the infrastructure page must use the canonical local instance identity rather than governed AI-summary text, so performance work cannot “optimize” the table into ambiguous labels that collapse multiple resources into the same visible name. -The same protected table path treats the visible platform column as -platform-first presentation over canonical merged-source data. Sort derivation -for that column must use the normalized infrastructure platform key, while the -render path may keep full merged-source detail in tooltips. When a row contains -both `agent` and a provider/API platform such as Proxmox, the table must render -the provider platform as the compact visible badge rather than adding extra -Agent badge width or sorting primarily by the telemetry method. +The same protected table path treats the visible system column as +identity-first presentation over canonical merged-source data. Sort derivation +for that column must use the same displayed system identity as the render path, +while the render path may keep full merged-source detail in tooltips. When a +row contains both `agent` and a provider/API platform such as Proxmox, the table +must render the provider platform as the compact visible badge rather than +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. That derived workload owner now also routes grouped row windowing through `frontend-modern/src/components/Dashboard/useGroupedTableWindowing.ts`, which owns row-window thresholds, overscan behavior, reveal-index clamping, and diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index deefac854..968533402 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -420,6 +420,11 @@ this health-state projection: it must derive counts only from the resources accessor already available to the summary state, not from a separate API call or a re-projection of websocket state outside the shared summary pipeline. +`resourceBadgePresentation.ts` now owns the Infrastructure table system +identity resolver. That resolver must prefer provider/API platform identity, +then reported host OS or appliance identity, before falling back to Docker or +other runtime capability labels. + This subsystem now sits under the dedicated core monitoring runtime lane so canonical resource identity, discovery normalization, and platform-runtime coverage stay governed as a first-class Pulse product surface, including the @@ -1800,15 +1805,19 @@ sources, `frontend-modern/src/utils/sourcePlatforms.ts` must still resolve the platform as `truenas` and the source mode as `hybrid`, so workload and infrastructure consumers do not collapse API-backed TrueNAS systems or apps back onto the generic agent path just because host telemetry is also present. -That same boundary also owns the infrastructure table's operator-facing -platform vocabulary. `frontend-modern/src/utils/resourceBadgePresentation.ts`, +That same boundary also owns the infrastructure table's operator-facing system +identity vocabulary. `frontend-modern/src/utils/resourceBadgePresentation.ts`, `frontend-modern/src/components/Infrastructure/resourceBadges.ts`, and the unified resource table sections may preserve full merged-source detail in -tooltips and accessibility metadata, but visible table headers, filters, sort -keys, and row badges must present the owning infrastructure platform first. -Agent telemetry is collection-method detail when a stronger platform source is -present, not a peer platform label that should crowd the table or drive the -primary platform sort. +tooltips and accessibility metadata, but visible table headers, sort keys, and +row badges must answer what system the operator is looking at. Provider/API +platforms such as Proxmox, TrueNAS, VMware, and Kubernetes outrank collection +methods; reported agent OS or appliance identity such as Unraid or Ubuntu +outranks a generic container-runtime capability; and Docker should appear as +the primary visible system label only when the container runtime is the best +available identity. Agent telemetry is collection-method detail when a stronger +platform or host identity is present, not a peer platform label that should +crowd the table or drive the primary system sort. The route file `frontend-modern/src/pages/Infrastructure.tsx` is now only the navigation boundary for that surface; canonical infrastructure filter, search, deep-link, and expansion state now live behind the dedicated infrastructure diff --git a/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadFilterConfigModel.test.ts b/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadFilterConfigModel.test.ts index e65319950..abbfdca6b 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadFilterConfigModel.test.ts +++ b/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadFilterConfigModel.test.ts @@ -111,7 +111,7 @@ describe('dashboardWorkloadFilterConfigModel', () => { isWorkloadsRoute: true, selectedPlatform: 'truenas', platformOptions: [ - { value: 'docker', label: 'Containers' }, + { value: 'docker', label: 'Docker' }, { value: 'truenas', label: 'TrueNAS' }, ], onChange, @@ -122,7 +122,7 @@ describe('dashboardWorkloadFilterConfigModel', () => { value: 'truenas', options: [ { value: '', label: 'All platforms' }, - { value: 'docker', label: 'Containers' }, + { value: 'docker', label: 'Docker' }, { value: 'truenas', label: 'TrueNAS' }, ], }); diff --git a/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts b/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts index aec547741..637d4d126 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts +++ b/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts @@ -143,8 +143,8 @@ describe('dashboardWorkloadRouteModel', () => { 'app-container', ), ).toEqual([ - { value: 'docker', label: 'Containers' }, { value: 'truenas', label: 'TrueNAS' }, + { value: 'docker', label: 'Docker' }, ]); }); diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx index d33a164d8..a5000703d 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx @@ -19,7 +19,8 @@ import { StackedDiskBar } from '@/components/Dashboard/StackedDiskBar'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { - getInfrastructurePlatformBadges, + dedupeResourceBadges, + getInfrastructureSystemIdentityBadges, getPlatformBadge, getSourceBadge, getUnifiedSourceBadges, @@ -304,8 +305,11 @@ export const UnifiedResourceHostTableCard: Component getSourceBadge(resource.sourceType)); const unifiedSources = createMemo(() => table.getUnifiedSources(resource)); const sourceBadges = createMemo(() => getUnifiedSourceBadges(unifiedSources())); - const platformBadges = createMemo(() => - getInfrastructurePlatformBadges(unifiedSources()), + const systemBadges = createMemo(() => + getInfrastructureSystemIdentityBadges(resource), + ); + const systemTitleBadges = createMemo(() => + dedupeResourceBadges([...systemBadges(), ...sourceBadges()]), ); const policyBadges = createMemo(() => getResourcePolicyTableBadges(resource.policy), @@ -589,10 +593,10 @@ export const UnifiedResourceHostTableCard: Component diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx index a0f123654..4b9e56625 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx @@ -16,7 +16,8 @@ import { TableRow, } from '@/components/shared/Table'; import { - getInfrastructurePlatformBadges, + dedupeResourceBadges, + getInfrastructureSystemIdentityBadges, getPlatformBadge, getSourceBadge, getUnifiedSourceBadges, @@ -121,8 +122,11 @@ export const UnifiedResourcePBSTableSection: Component getSourceBadge(resource.sourceType)); const unifiedSources = createMemo(() => table.getUnifiedSources(resource)); const sourceBadges = createMemo(() => getUnifiedSourceBadges(unifiedSources())); - const platformBadges = createMemo(() => - getInfrastructurePlatformBadges(unifiedSources()), + const systemBadges = createMemo(() => + getInfrastructureSystemIdentityBadges(resource), + ); + const systemTitleBadges = createMemo(() => + dedupeResourceBadges([...systemBadges(), ...sourceBadges()]), ); const healthClass = createMemo( () => @@ -256,10 +260,10 @@ export const UnifiedResourcePBSTableSection: Component diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx index 4ea607d77..e217e4454 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx @@ -16,7 +16,8 @@ import { TableRow, } from '@/components/shared/Table'; import { - getInfrastructurePlatformBadges, + dedupeResourceBadges, + getInfrastructureSystemIdentityBadges, getPlatformBadge, getSourceBadge, getUnifiedSourceBadges, @@ -135,8 +136,11 @@ export const UnifiedResourcePMGTableSection: Component getSourceBadge(resource.sourceType)); const unifiedSources = createMemo(() => table.getUnifiedSources(resource)); const sourceBadges = createMemo(() => getUnifiedSourceBadges(unifiedSources())); - const platformBadges = createMemo(() => - getInfrastructurePlatformBadges(unifiedSources()), + const systemBadges = createMemo(() => + getInfrastructureSystemIdentityBadges(resource), + ); + const systemTitleBadges = createMemo(() => + dedupeResourceBadges([...systemBadges(), ...sourceBadges()]), ); const healthClass = createMemo( () => @@ -286,10 +290,10 @@ export const UnifiedResourcePMGTableSection: Component diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx index a853b7060..31b0e71d9 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourceSourceBadgeCell.tsx @@ -14,14 +14,6 @@ interface UnifiedResourceSourceBadgeCellProps { const getVisibleSourceBadgeLimit = (layoutMode: UnifiedResourceTableLayoutMode): number => layoutMode === 'wide' ? 3 : 2; -const compactSourceBadgeLabel = (label: string): string => - label === 'Containers' ? 'Cont' : label; - -const getSourceBadgeDisplayLabel = ( - label: string, - layoutMode: UnifiedResourceTableLayoutMode, -): string => (layoutMode === 'wide' ? label : compactSourceBadgeLabel(label)); - export const UnifiedResourceSourceBadgeCell: Component = ( props, ) => { @@ -40,9 +32,7 @@ export const UnifiedResourceSourceBadgeCell: Component badges().slice(visibleBadges().length)); const hiddenBadgeCount = createMemo(() => Math.max(0, badges().length - visibleBadges().length)); const hiddenBadgeLabel = createMemo(() => - hiddenBadgeCount() === 1 - ? `+${compactSourceBadgeLabel(hiddenBadges()[0]?.label ?? '')}` - : `+${hiddenBadgeCount()}`, + hiddenBadgeCount() === 1 ? `+${hiddenBadges()[0]?.label ?? ''}` : `+${hiddenBadgeCount()}`, ); const title = createMemo(() => titleBadges() @@ -53,7 +43,7 @@ export const UnifiedResourceSourceBadgeCell: Component @@ -62,16 +52,14 @@ export const UnifiedResourceSourceBadgeCell: Component - - {getSourceBadgeDisplayLabel(badge.label, props.layoutMode)} - + {badge.label} )} 0}> badge.title ?? badge.label) .join(', ')}`} title={title()} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ReportMergeModal.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ReportMergeModal.test.tsx index 54cf45164..c0ac9f606 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ReportMergeModal.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ReportMergeModal.test.tsx @@ -71,7 +71,7 @@ describe('ReportMergeModal', () => { expect(screen.getByText('PVE')).toBeInTheDocument(); expect(screen.getByText('Agent')).toBeInTheDocument(); - expect(screen.getByText('Containers')).toBeInTheDocument(); + expect(screen.getByText('Docker')).toBeInTheDocument(); expect(screen.getByText('PBS')).toBeInTheDocument(); expect(screen.getByText('PMG')).toBeInTheDocument(); expect(screen.getByText('K8s')).toBeInTheDocument(); @@ -91,7 +91,7 @@ describe('ReportMergeModal', () => { render(() => ); expect(screen.getByText('PVE')).toBeInTheDocument(); - expect(screen.getByText('Containers')).toBeInTheDocument(); + expect(screen.getByText('Docker')).toBeInTheDocument(); }); it('shows a notes textarea with placeholder', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx index 4c4bb4fa2..fde56315c 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx @@ -184,6 +184,39 @@ describe('UnifiedResourceTable performance contract', () => { expect(container.querySelector('[style]')).toBeNull(); }); + it('renders reported host identity instead of container runtime as the primary system badge', async () => { + const resources = [ + makeResource(1, { + type: 'docker-host', + displayName: 'tower', + platformType: 'docker', + sourceType: 'hybrid', + platformData: { + sources: ['agent', 'docker'], + agent: { + platform: 'unraid', + osName: 'Unraid', + osVersion: '7.1.0', + }, + }, + }), + ]; + const { getByText, queryByText } = render(() => ( + + )); + + await waitFor(() => { + expect(getByText('System')).toBeInTheDocument(); + expect(getByText('Unraid')).toBeInTheDocument(); + }); + expect(queryByText('Docker')).toBeNull(); + }); + it('adapts host columns from the measured table surface width instead of the window width', async () => { const resources = [ makeResource(1, { @@ -210,7 +243,7 @@ describe('UnifiedResourceTable performance contract', () => { await waitFor(() => { expect(getByText('Net').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Plat').closest('th')).not.toHaveClass('hidden'); + expect(getByText('Sys').closest('th')).not.toHaveClass('hidden'); expect(getByText('I/O').closest('th')).toHaveClass('hidden'); expect(getByText('Up').closest('th')).toHaveClass('hidden'); }); @@ -223,7 +256,7 @@ describe('UnifiedResourceTable performance contract', () => { await waitFor(() => { expect(getByText('Net').closest('th')).not.toHaveClass('hidden'); expect(getByText('I/O').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Plat').closest('th')).not.toHaveClass('hidden'); + expect(getByText('Sys').closest('th')).not.toHaveClass('hidden'); expect(getByText('Up').closest('th')).toHaveClass('hidden'); }); @@ -232,7 +265,7 @@ describe('UnifiedResourceTable performance contract', () => { await waitFor(() => { expect(getByText('Net I/O').closest('th')).not.toHaveClass('hidden'); expect(getByText('Disk I/O').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Platform').closest('th')).not.toHaveClass('hidden'); + expect(getByText('System').closest('th')).not.toHaveClass('hidden'); expect(getByText('Up').closest('th')).not.toHaveClass('hidden'); expect(getByText('Temp').closest('th')).not.toHaveClass('hidden'); }); @@ -241,7 +274,7 @@ describe('UnifiedResourceTable performance contract', () => { await waitFor(() => { expect(getByText('Memory')).toBeInTheDocument(); - expect(getByText('Platform').closest('th')).not.toHaveClass('hidden'); + expect(getByText('System').closest('th')).not.toHaveClass('hidden'); expect(getByText('Uptime').closest('th')).not.toHaveClass('hidden'); }); }); @@ -276,7 +309,7 @@ describe('UnifiedResourceTable performance contract', () => { await waitFor(() => { expect(getByText('Store').closest('th')).not.toHaveClass('hidden'); expect(getByText('Jobs').closest('th')).not.toHaveClass('hidden'); - expect(getByText('Plat').closest('th')).not.toHaveClass('hidden'); + expect(getByText('Sys').closest('th')).not.toHaveClass('hidden'); expect(getByText('Up').closest('th')).toHaveClass('hidden'); }); expect(getByText('PBS')).toBeInTheDocument(); @@ -284,7 +317,7 @@ describe('UnifiedResourceTable performance contract', () => { expect(queryByText('PBS+Agent')).toBeNull(); expect(queryByText('+1')).toBeNull(); expect( - container.querySelectorAll('[aria-label^="Platform:"] .h-1\\.5.w-1\\.5.rounded-full'), + container.querySelectorAll('[aria-label^="System:"] .h-1\\.5.w-1\\.5.rounded-full'), ).toHaveLength(0); emitResizeObserverWidth(660); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts index 615a80cc6..352904035 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts @@ -229,7 +229,7 @@ describe('infrastructureSelectors', () => { ]); }); - it('sorts the visible platform column by infrastructure platform, not telemetry method', () => { + it('sorts the visible system column by displayed infrastructure identity', () => { const resources = [ makeResource(1, { displayName: 'PVE hybrid', @@ -237,22 +237,32 @@ describe('infrastructureSelectors', () => { sourceType: 'hybrid', }), makeResource(2, { - displayName: 'Agent only', - platformType: 'agent', - sourceType: 'agent', + displayName: 'Docker only', + type: 'docker-host', + platformType: 'docker', + sourceType: 'api', + platformData: { sources: ['docker'] }, }), makeResource(3, { - displayName: 'Kubernetes', - platformType: 'kubernetes', + displayName: 'Unraid runtime', + type: 'docker-host', + platformType: 'docker', sourceType: 'hybrid', + platformData: { + sources: ['agent', 'docker'], + agent: { + platform: 'unraid', + osName: 'Unraid', + }, + }, }), ]; const sorted = sortResources(resources, 'source', 'asc'); expect(sorted.map((resource) => resource.id)).toEqual([ 'resource-2', - 'resource-3', 'resource-1', + 'resource-3', ]); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts index 997094e80..0e5f0d034 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceBadges.test.ts @@ -12,7 +12,7 @@ describe('getUnifiedSourceBadges', () => { const badges = getUnifiedSourceBadges(['proxmox-pve', 'docker']); expect(badges).toHaveLength(2); expect(badges[0].label).toBe('PVE'); - expect(badges[1].label).toBe('Containers'); + expect(badges[1].label).toBe('Docker'); }); it('includes TrueNAS source badge', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts index 42edcfc78..42a367321 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts @@ -159,11 +159,11 @@ describe('unifiedResourceTableStateModel', () => { expect(wideColumns.ioColumn.width).toBe('12.5%'); expect(wideColumns.serviceActionColumn.width).toBe('16%'); expect(getUnifiedResourceTableHeaderLabels('wide').memory).toBe('Memory'); - expect(getUnifiedResourceTableHeaderLabels('wide').source).toBe('Platform'); + expect(getUnifiedResourceTableHeaderLabels('wide').source).toBe('System'); expect(getUnifiedResourceTableHeaderLabels('compact').memory).toBe('Mem'); - expect(getUnifiedResourceTableHeaderLabels('compact').source).toBe('Platform'); + expect(getUnifiedResourceTableHeaderLabels('compact').source).toBe('System'); expect(getUnifiedResourceTableHeaderLabels('tablet').network).toBe('Net'); - expect(getUnifiedResourceTableHeaderLabels('tablet').source).toBe('Plat'); + expect(getUnifiedResourceTableHeaderLabels('tablet').source).toBe('Sys'); expect(getUnifiedResourceTableHeaderLabels('mobile').datastores).toBe('Store'); expect(shouldShowUnifiedResourceHostTable(0, 0)).toBe(true); expect(shouldShowUnifiedResourceHostTable(0, 2)).toBe(false); diff --git a/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts b/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts index be46b3e38..f71b9c9fe 100644 --- a/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts +++ b/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts @@ -4,6 +4,7 @@ import { getPreferredInfrastructureDisplayName, getPreferredResourceDisplayName, } from '@/utils/resourceIdentity'; +import { getInfrastructureSystemIdentitySortLabel } from '@/utils/resourceBadgePresentation'; import { normalizeSourcePlatformKey, type KnownSourcePlatform } from '@/utils/sourcePlatforms'; import { getCanonicalStatusLabel, STATUS_SORT_ORDER } from '@/utils/status'; import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; @@ -89,7 +90,7 @@ const getSortValue = (resource: Resource, key: string): number | string | null = case 'diskio': return resource.diskIO ? resource.diskIO.readRate + resource.diskIO.writeRate : null; case 'source': - return resource.platformType ?? ''; + return getInfrastructureSystemIdentitySortLabel(resource); case 'temp': return resource.temperature ?? null; default: diff --git a/frontend-modern/src/components/Infrastructure/resourceBadges.ts b/frontend-modern/src/components/Infrastructure/resourceBadges.ts index 1176a025f..32ebdc153 100644 --- a/frontend-modern/src/components/Infrastructure/resourceBadges.ts +++ b/frontend-modern/src/components/Infrastructure/resourceBadges.ts @@ -1,6 +1,8 @@ export type { ResourceBadge } from '@/utils/resourceBadgePresentation'; export { getContainerRuntimeBadge, + getInfrastructureSystemIdentityBadges, + getInfrastructureSystemIdentitySortLabel, getInfrastructurePlatformBadges, getPlatformBadge, getSourceBadge, diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts index 8a6df9c09..f8f58ea1f 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts @@ -67,7 +67,7 @@ export const buildSourceSections = ( return [ { id: 'proxmox', label: 'Proxmox', payload: platformData.proxmox }, { id: 'agent', label: 'Agent', payload: platformData.agent }, - { id: 'docker', label: 'Containers', payload: platformData.docker }, + { id: 'docker', label: 'Docker', payload: platformData.docker }, { id: 'pbs', label: 'PBS', payload: platformData.pbs }, { id: 'pmg', label: 'PMG', payload: platformData.pmg }, { id: 'kubernetes', label: 'Kubernetes', payload: platformData.kubernetes }, diff --git a/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts b/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts index 4e13312de..f0d65eff2 100644 --- a/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts +++ b/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts @@ -287,7 +287,7 @@ export const getUnifiedResourceTableHeaderLabels = ( disk: 'Disk', network: 'Net I/O', diskIo: 'Disk I/O', - source: 'Platform', + source: 'System', uptime: 'Uptime', temp: 'Temp', datastores: 'Datastores', @@ -309,7 +309,7 @@ export const getUnifiedResourceTableHeaderLabels = ( disk: 'Disk', network: 'Net I/O', diskIo: 'Disk I/O', - source: 'Platform', + source: 'System', uptime: 'Up', temp: 'Temp', datastores: 'Stores', @@ -330,7 +330,7 @@ export const getUnifiedResourceTableHeaderLabels = ( disk: 'Disk', network: 'Net', diskIo: 'I/O', - source: 'Plat', + source: 'Sys', uptime: 'Up', temp: 'C', datastores: 'Store', diff --git a/frontend-modern/src/components/shared/__tests__/sourcePlatformBadges.test.ts b/frontend-modern/src/components/shared/__tests__/sourcePlatformBadges.test.ts index 5a8d8aed6..2ab201357 100644 --- a/frontend-modern/src/components/shared/__tests__/sourcePlatformBadges.test.ts +++ b/frontend-modern/src/components/shared/__tests__/sourcePlatformBadges.test.ts @@ -46,9 +46,9 @@ describe('sourcePlatformBadges', () => { expect(result?.label).toBe('PMG'); }); - it('returns Containers badge for docker', () => { + it('returns Docker badge for docker', () => { const result = getSourcePlatformBadge('docker'); - expect(result?.label).toBe('Containers'); + expect(result?.label).toBe('Docker'); }); it('returns K8s badge for kubernetes', () => { @@ -108,9 +108,9 @@ describe('sourcePlatformBadges', () => { }); it('is case-insensitive', () => { - expect(getSourcePlatformBadge('DOCKER')?.label).toBe('Containers'); + expect(getSourcePlatformBadge('DOCKER')?.label).toBe('Docker'); expect(getSourcePlatformBadge('Kubernetes')?.label).toBe('K8s'); - expect(getSourcePlatformBadge('Docker')?.label).toBe('Containers'); + expect(getSourcePlatformBadge('Docker')?.label).toBe('Docker'); }); it('returns unknown platform badge for unrecognized platforms', () => { @@ -121,7 +121,7 @@ describe('sourcePlatformBadges', () => { it('handles whitespace around platform names', () => { const result = getSourcePlatformBadge(' docker '); - expect(result?.label).toBe('Containers'); + expect(result?.label).toBe('Docker'); }); it('handles platform names with underscores', () => { diff --git a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts index 5c3cb536f..6d29d058f 100644 --- a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts @@ -2,16 +2,33 @@ import { describe, expect, it } from 'vitest'; import { dedupeResourceBadges, getInfrastructurePlatformBadges, + getInfrastructureSystemIdentityBadges, + getInfrastructureSystemIdentitySortLabel, getPlatformBadge, getSourceBadge, getTypeBadge, getUnifiedSourceBadges, } from '@/utils/resourceBadgePresentation'; +import type { Resource } from '@/types/resource'; + +const makeResource = (overrides: Partial): Resource => ({ + id: 'resource-1', + type: 'agent', + name: 'host-1', + displayName: 'host-1', + platformId: 'host-1', + platformType: 'agent', + sourceType: 'agent', + status: 'online', + lastSeen: 1, + ...overrides, +}); describe('resourceBadgePresentation', () => { it('returns canonical platform badges via shared platform presentation', () => { expect(getPlatformBadge('proxmox-pve')?.label).toBe('PVE'); expect(getPlatformBadge('proxmox-pbs')?.label).toBe('PBS'); + expect(getPlatformBadge('docker')?.label).toBe('Docker'); }); it('returns source badges for infrastructure source types', () => { @@ -42,6 +59,87 @@ describe('resourceBadgePresentation', () => { ]); }); + it('shows explicit host identity before container runtime capability', () => { + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'docker-host', + platformType: 'docker', + sourceType: 'hybrid', + platformData: { + sources: ['agent', 'docker'], + agent: { + platform: 'unraid', + osName: 'Unraid', + osVersion: '7.1.0', + }, + }, + }), + ).map((badge) => badge.label), + ).toEqual(['Unraid']); + + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'docker-host', + platformType: 'docker', + sourceType: 'api', + platformData: { sources: ['docker'] }, + }), + ).map((badge) => badge.label), + ).toEqual(['Docker']); + }); + + it('keeps API-backed platform identity ahead of reported host OS', () => { + const resource = makeResource({ + platformType: 'proxmox-pve', + sourceType: 'hybrid', + platformData: { + sources: ['agent', 'proxmox-pve'], + agent: { + platform: 'debian', + osName: 'Debian GNU/Linux', + osVersion: '12', + }, + }, + }); + + expect(getInfrastructureSystemIdentityBadges(resource).map((badge) => badge.label)).toEqual([ + 'PVE', + ]); + expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('PVE'); + }); + + it('falls back to reported OS identity for agent-only systems', () => { + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + platformData: { + sources: ['agent'], + agent: { + platform: 'linux', + osName: 'Ubuntu 24.04.2 LTS', + }, + }, + }), + ).map((badge) => badge.label), + ).toEqual(['Ubuntu']); + + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + platformData: { + sources: ['agent'], + agent: { + platform: 'qnap', + osName: 'QNAP QTS', + }, + }, + }), + ).map((badge) => badge.label), + ).toEqual(['QNAP']); + }); + it('deduplicates repeated header badge labels', () => { const badges = dedupeResourceBadges([ getTypeBadge('agent'), diff --git a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts index f959c3ee3..a2ec3becf 100644 --- a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts +++ b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts @@ -42,7 +42,7 @@ describe('sourcePlatforms', () => { describe('getSourcePlatformLabel', () => { it('returns label for known platforms', () => { - expect(getSourcePlatformLabel('docker')).toBe('Containers'); + expect(getSourcePlatformLabel('docker')).toBe('Docker'); expect(getSourcePlatformLabel('kubernetes')).toBe('K8s'); }); diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index 0ab5b3158..821e5142e 100644 --- a/frontend-modern/src/utils/platformSupportManifest.generated.ts +++ b/frontend-modern/src/utils/platformSupportManifest.generated.ts @@ -1,11 +1,11 @@ // This file is generated by scripts/release_control/generate_platform_support_frontend_module.py. // Do not edit by hand. // Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json -// Source SHA256: 31ea4c3a28507c3d60b40d747b93aadbbf7c5b4b51b546e9e803fb34eb519acd +// Source SHA256: 27bdbf4c2820a60a74c991e28c9f5876062b53811ddbd420f834dbbc757dd715 export const PLATFORM_SUPPORT_MANIFEST_SOURCE = { path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json', - sha256: '31ea4c3a28507c3d60b40d747b93aadbbf7c5b4b51b546e9e803fb34eb519acd', + sha256: '27bdbf4c2820a60a74c991e28c9f5876062b53811ddbd420f834dbbc757dd715', } as const; export const PLATFORM_SUPPORT_MANIFEST = { schemaVersion: 1, @@ -61,10 +61,10 @@ export const PLATFORM_SUPPORT_MANIFEST = { assistantRead: 'supported', assistantControl: 'supported', }, - uiLabel: 'Containers', + uiLabel: 'Docker', uiTone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400', aliases: [], - displayTokens: ['Containers', 'Docker'], + displayTokens: ['Container runtime', 'Docker'], storageFamily: 'container', }, { @@ -446,8 +446,8 @@ export const SOURCE_PLATFORM_AUDIT_TOKENS = [ ] as const; export const SOURCE_PLATFORM_DISPLAY_TOKENS = [ 'Agent', - 'Containers', 'Docker', + 'Container runtime', 'K8s', 'Kubernetes', 'PVE', @@ -699,7 +699,7 @@ export const SOURCE_PLATFORM_PRESENTATION = { tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-400', }, docker: { - label: 'Containers', + label: 'Docker', tone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400', }, kubernetes: { diff --git a/frontend-modern/src/utils/resourceBadgePresentation.ts b/frontend-modern/src/utils/resourceBadgePresentation.ts index c9ab97e8f..32a58ad29 100644 --- a/frontend-modern/src/utils/resourceBadgePresentation.ts +++ b/frontend-modern/src/utils/resourceBadgePresentation.ts @@ -1,5 +1,6 @@ -import type { PlatformType, SourceType, ResourceType } from '@/types/resource'; +import type { PlatformType, SourceType, Resource, ResourceType } from '@/types/resource'; import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; +import { getPlatformAgentRecord, getPlatformDataRecord } from '@/utils/agentResources'; import { normalizeSourcePlatformKey, type KnownSourcePlatform } from '@/utils/sourcePlatforms'; import { getSourceTypePresentation } from '@/utils/sourceTypePresentation'; import { @@ -18,6 +19,58 @@ const baseBadge = const typeClasses = 'bg-surface-alt text-base-content'; +const PRIMARY_SYSTEM_SOURCE_PRIORITY: KnownSourcePlatform[] = [ + 'proxmox-pve', + 'proxmox-pbs', + 'proxmox-pmg', + 'truenas', + 'vmware-vsphere', + 'unraid', + 'synology-dsm', + 'microsoft-hyperv', + 'kubernetes', +]; + +const knownHostIdentityPlatformPatterns: Array<{ + pattern: RegExp; + source: KnownSourcePlatform; +}> = [ + { pattern: /\btrue\s*nas\b|\btruenas\b/i, source: 'truenas' }, + { pattern: /\bunraid\b/i, source: 'unraid' }, + { pattern: /\bsynology\b|\bdiskstation\b|\bdsm\b/i, source: 'synology-dsm' }, + { pattern: /\bhyper-?v\b/i, source: 'microsoft-hyperv' }, +]; + +const hostOsLabelPatterns: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /\bqnap\b|\bqts\b|\bquts\b/i, label: 'QNAP' }, + { pattern: /\bubuntu\b/i, label: 'Ubuntu' }, + { pattern: /\bdebian\b/i, label: 'Debian' }, + { pattern: /\bproxmox\b/i, label: 'Proxmox' }, + { pattern: /\bfedora\b/i, label: 'Fedora' }, + { pattern: /\brocky\b/i, label: 'Rocky' }, + { pattern: /\balma\s*linux\b|\balmalinux\b/i, label: 'AlmaLinux' }, + { pattern: /\bcentos\b/i, label: 'CentOS' }, + { pattern: /\bred\s*hat\b|\brhel\b/i, label: 'RHEL' }, + { pattern: /\barch\b/i, label: 'Arch' }, + { pattern: /\balpine\b/i, label: 'Alpine' }, + { pattern: /\bopen\s*suse\b|\bopensuse\b/i, label: 'openSUSE' }, + { pattern: /\bsuse\b/i, label: 'SUSE' }, + { pattern: /\bfreebsd\b/i, label: 'FreeBSD' }, + { pattern: /\bwindows\b/i, label: 'Windows' }, + { pattern: /\bmac\s*os\b|\bmacos\b|\bdarwin\b/i, label: 'macOS' }, + { pattern: /\blinux\b/i, label: 'Linux' }, +]; + +const trimString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); + +const titleFromParts = (...parts: Array): string | undefined => { + const title = parts + .map((part) => (part || '').trim()) + .filter(Boolean) + .join(' '); + return title || undefined; +}; + const normalizeUnifiedSourceKeys = (sources?: string[] | null): KnownSourcePlatform[] => { if (!sources || sources.length === 0) return []; const normalized = sources @@ -83,6 +136,115 @@ export function getInfrastructurePlatformBadges(sources?: string[] | null): Reso return buildUnifiedSourceBadges(platformSources.length > 0 ? platformSources : normalized); } +const firstSystemSource = ( + sources: KnownSourcePlatform[], + platformType?: PlatformType, +): KnownSourcePlatform | null => { + const sourceSet = new Set(sources); + const normalizedPlatform = normalizeSourcePlatformKey(platformType); + if (normalizedPlatform) { + sourceSet.add(normalizedPlatform); + } + + return PRIMARY_SYSTEM_SOURCE_PRIORITY.find((source) => sourceSet.has(source)) ?? null; +}; + +const getKnownHostIdentitySource = (...values: string[]): KnownSourcePlatform | null => { + for (const value of values) { + if (!value) continue; + const normalized = normalizeSourcePlatformKey(value); + if ( + normalized && + normalized !== 'agent' && + normalized !== 'docker' && + normalized !== 'generic' + ) { + return normalized; + } + const match = knownHostIdentityPlatformPatterns.find(({ pattern }) => pattern.test(value)); + if (match) return match.source; + } + return null; +}; + +const getHostOsLabel = (...values: string[]): string | null => { + for (const value of values) { + if (!value) continue; + const match = hostOsLabelPatterns.find(({ pattern }) => pattern.test(value)); + if (match) return match.label; + } + return null; +}; + +const getAgentSystemIdentityBadge = (resource: Resource): ResourceBadge | null => { + const agent = getPlatformAgentRecord(resource); + if (!agent) return null; + + const platform = trimString(agent.platform); + const osName = trimString(agent.osName); + const osVersion = trimString(agent.osVersion); + const knownSource = getKnownHostIdentitySource(platform, osName); + if (knownSource) { + const badge = getSourcePlatformBadge(knownSource); + if (badge) { + return { + label: badge.label, + classes: badge.classes, + title: titleFromParts(osName || badge.title, osVersion) ?? badge.title, + }; + } + } + + const osLabel = getHostOsLabel(osName, platform); + if (osLabel) { + return { + label: osLabel, + classes: `${baseBadge} ${typeClasses}`, + title: titleFromParts(osName || osLabel, osVersion), + }; + } + + return null; +}; + +export function getInfrastructureSystemIdentityBadges(resource: Resource): ResourceBadge[] { + const platformData = getPlatformDataRecord(resource) as { sources?: string[] } | undefined; + const sources = normalizeUnifiedSourceKeys(platformData?.sources); + const systemSource = firstSystemSource(sources, resource.platformType); + if (systemSource) { + return buildUnifiedSourceBadges([systemSource]); + } + + const agentIdentityBadge = getAgentSystemIdentityBadge(resource); + if (agentIdentityBadge) { + return [agentIdentityBadge]; + } + + if ( + resource.platformType === 'docker' || + resource.type === 'docker-host' || + sources.includes('docker') + ) { + return buildUnifiedSourceBadges(['docker']); + } + + const platformBadge = getPlatformBadge(resource.platformType); + if (platformBadge) { + return [platformBadge]; + } + + return getInfrastructurePlatformBadges(platformData?.sources); +} + +export function getInfrastructureSystemIdentitySortLabel(resource: Resource): string { + return ( + getInfrastructureSystemIdentityBadges(resource)[0]?.label || + getPlatformBadge(resource.platformType)?.label || + resource.platformType || + '' + ); +} + export function dedupeResourceBadges( badges: Array, ): ResourceBadge[] {