Clarify infrastructure system identity badges

This commit is contained in:
rcourtman 2026-04-28 14:43:01 +01:00
parent 62d3fa3e58
commit bb7b607ca5
24 changed files with 413 additions and 93 deletions

View file

@ -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"

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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' },
],
});

View file

@ -143,8 +143,8 @@ describe('dashboardWorkloadRouteModel', () => {
'app-container',
),
).toEqual([
{ value: 'docker', label: 'Containers' },
{ value: 'truenas', label: 'TrueNAS' },
{ value: 'docker', label: 'Docker' },
]);
});

View file

@ -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<UnifiedResourceHostTableCar
const sourceBadge = createMemo(() => 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<UnifiedResourceHostTableCar
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
>
<UnifiedResourceSourceBadgeCell
unifiedBadges={platformBadges()}
unifiedBadges={systemBadges()}
platformBadge={platformBadge()}
sourceBadge={sourceBadge()}
titleBadges={sourceBadges()}
titleBadges={systemTitleBadges()}
layoutMode={table.layoutMode()}
/>
</TableCell>

View file

@ -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<UnifiedResourcePBSTableSe
const sourceBadge = createMemo(() => 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<UnifiedResourcePBSTableSe
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<UnifiedResourceSourceBadgeCell
unifiedBadges={platformBadges()}
unifiedBadges={systemBadges()}
platformBadge={platformBadge()}
sourceBadge={sourceBadge()}
titleBadges={sourceBadges()}
titleBadges={systemTitleBadges()}
layoutMode={table.layoutMode()}
/>
</TableCell>

View file

@ -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<UnifiedResourcePMGTableSe
const sourceBadge = createMemo(() => 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<UnifiedResourcePMGTableSe
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<UnifiedResourceSourceBadgeCell
unifiedBadges={platformBadges()}
unifiedBadges={systemBadges()}
platformBadge={platformBadge()}
sourceBadge={sourceBadge()}
titleBadges={sourceBadges()}
titleBadges={systemTitleBadges()}
layoutMode={table.layoutMode()}
/>
</TableCell>

View file

@ -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<UnifiedResourceSourceBadgeCellProps> = (
props,
) => {
@ -40,9 +32,7 @@ export const UnifiedResourceSourceBadgeCell: Component<UnifiedResourceSourceBadg
const hiddenBadges = createMemo(() => 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<UnifiedResourceSourceBadg
return (
<div
class="flex min-w-0 max-w-full items-center justify-center gap-1 overflow-hidden"
aria-label={title() ? `Platform: ${title()}` : undefined}
aria-label={title() ? `System: ${title()}` : undefined}
title={title()}
>
<For each={visibleBadges()}>
@ -62,16 +52,14 @@ export const UnifiedResourceSourceBadgeCell: Component<UnifiedResourceSourceBadg
class={`${badge.classes} min-w-0 max-w-full overflow-hidden px-1`}
title={badge.title}
>
<span class="min-w-0 truncate">
{getSourceBadgeDisplayLabel(badge.label, props.layoutMode)}
</span>
<span class="min-w-0 truncate">{badge.label}</span>
</span>
)}
</For>
<Show when={hiddenBadgeCount() > 0}>
<span
class="inline-flex min-w-0 max-w-full items-center overflow-hidden rounded bg-surface-alt px-1 py-0.5 text-[10px] font-medium text-muted"
aria-label={`Additional sources: ${hiddenBadges()
aria-label={`Additional systems: ${hiddenBadges()
.map((badge) => badge.title ?? badge.label)
.join(', ')}`}
title={title()}

View file

@ -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(() => <ReportMergeModal {...props} />);
expect(screen.getByText('PVE')).toBeInTheDocument();
expect(screen.getByText('Containers')).toBeInTheDocument();
expect(screen.getByText('Docker')).toBeInTheDocument();
});
it('shows a notes textarea with placeholder', () => {

View file

@ -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(() => (
<UnifiedResourceTable
resources={resources}
expandedResourceId={null}
onExpandedResourceChange={vi.fn()}
groupingMode="flat"
/>
));
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);

View file

@ -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',
]);
});

View file

@ -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', () => {

View file

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

View file

@ -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:

View file

@ -1,6 +1,8 @@
export type { ResourceBadge } from '@/utils/resourceBadgePresentation';
export {
getContainerRuntimeBadge,
getInfrastructureSystemIdentityBadges,
getInfrastructureSystemIdentitySortLabel,
getInfrastructurePlatformBadges,
getPlatformBadge,
getSourceBadge,

View file

@ -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 },

View file

@ -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',

View file

@ -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', () => {

View file

@ -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>): 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'),

View file

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

View file

@ -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: {

View file

@ -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>): 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 | null | undefined>,
): ResourceBadge[] {