diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 36a30cc9f..33efbd2b2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -333,6 +333,9 @@ metadata-only API responses, and the unified-resource owner supplies the Namespace, ConfigMap, Secret, and ServiceAccount-specific columns. Kubernetes Node inventory must also be reachable through a dedicated native tab, not only the overview stack, while retaining the shared `PlatformSectionTabs` shell. +Primary app-shell navigation consumes unified-resources-owned resource evidence: +empty compatibility facets such as `docker: {}` do not admit runtime-lens tabs +on their own. Feature-owned Docker / Podman action controls may render backend `actionReadiness` disabled reasons, but the shared primitive layer owns only the button/table affordance shell; it must not invent action availability, command diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 4c2512b20..94e207f60 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5914,6 +5914,7 @@ "frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx", "frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx", "frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx", + "frontend-modern/src/features/platformNavigation/platformNavigationModel.ts", "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", @@ -6234,6 +6235,7 @@ "label": "resource runtime adapter helper proof", "match_prefixes": [], "match_files": [ + "frontend-modern/src/features/platformNavigation/platformNavigationModel.ts", "frontend-modern/src/utils/agentResources.ts", "frontend-modern/src/utils/resourcePlatformData.ts", "frontend-modern/src/utils/resourceStateAdapters.ts" @@ -6241,6 +6243,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts", "frontend-modern/src/utils/__tests__/agentResources.test.ts", "frontend-modern/src/utils/__tests__/resourcePlatformData.test.ts", "frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts" diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ab9e204ec..e4f0b1f68 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -123,6 +123,7 @@ reserved for providers that report 0..1 usage ratios. 88. `frontend-modern/src/utils/platformSupportManifest.generated.ts` 89. `internal/unifiedresources/kubernetes_metric_ids.go` 90. `internal/unifiedresources/policy_posture.go` +91. `frontend-modern/src/features/platformNavigation/platformNavigationModel.ts` 91. `internal/unifiedresources/clone.go` 92. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerPresentation.ts` 93. `internal/unifiedresources/storage_consumers.go` @@ -452,6 +453,11 @@ legacy/direct object routes fall back to `Overview` when the requested workflow has no rows for the current setup. Signals outside unified-resource inventory, such as TrueNAS recovery protection points or vSphere activity timeline rows, must be treated as explicit tab evidence rather than permanent navigation. +Primary platform navigation is also resource-evidence gated: runtime lenses such +as Docker / Podman must be admitted from explicit source scopes, Docker host or +service resource types, or non-empty Docker runtime metadata. Empty compatibility +facets such as `docker: {}` on a plain machine agent are not Docker inventory and +must not create a transient Docker tab during hydration. Kubernetes configuration and policy inventory are unified-resource consumer boundaries: the `/kubernetes/configuration` workflow tab must render Namespace, diff --git a/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts b/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts index 762e69c3e..48e7edc0f 100644 --- a/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts +++ b/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts @@ -79,6 +79,21 @@ describe('platformNavigationModel', () => { expect(visibility.standalone).toBe(true); }); + it('does not show Docker for a machine agent with an empty Docker facet', () => { + const tower = resource({ + id: 'tower', + platformType: 'agent', + type: 'agent', + docker: {}, + platformData: { agent: { hostname: 'tower' }, docker: {} }, + }); + const visibility = buildPrimaryPlatformNavigationVisibility([tower]); + + expect(collectResourcePlatformEvidence(tower)).toEqual(['agent']); + expect(visibility.docker).toBe(false); + expect(visibility.standalone).toBe(true); + }); + it('does not infer Docker from TrueNAS-scoped app metadata', () => { const truenasApp = resource({ id: 'truenas-app', diff --git a/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts b/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts index 1482c5a3a..760d3daca 100644 --- a/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts +++ b/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts @@ -1,6 +1,6 @@ import type { Accessor } from 'solid-js'; import type { Resource, ResourceType } from '@/types/resource'; -import { isPulseAgentPlatformResource } from '@/utils/agentResources'; +import { hasDockerFacetEvidence, isPulseAgentPlatformResource } from '@/utils/agentResources'; import { ADMITTED_PLATFORM_IDS, SUPPORTED_PLATFORM_IDS, @@ -33,10 +33,7 @@ export const PRIMARY_PLATFORM_NAV_IDS: readonly PrimaryPlatformNavId[] = [ 'standalone', ] as const; -export const PRIMARY_PLATFORM_NAV_SCOPE_IDS: Record< - PrimaryPlatformNavId, - readonly string[] -> = { +export const PRIMARY_PLATFORM_NAV_SCOPE_IDS: Record = { proxmox: ['proxmox-pve', 'proxmox-pbs', 'proxmox-pmg'], docker: ['docker'], kubernetes: ['kubernetes'], @@ -100,6 +97,8 @@ const addDirectDockerRuntimeEvidence = ( platformData: Record | null, ): void => { const resolvedPlatformType = resolveResourcePlatformType(resource); + const hasDockerMetadata = + hasDockerFacetEvidence(resource.docker) || hasDockerFacetEvidence(platformData?.docker); if (normalizeSourcePlatformKey(resource.platformType) === 'docker') { ids.add('docker'); return; @@ -112,7 +111,7 @@ const addDirectDockerRuntimeEvidence = ( ids.add('docker'); return; } - if (ids.has('agent') && (resource.docker || asRecord(platformData?.docker))) { + if (ids.has('agent') && hasDockerMetadata) { ids.add('docker'); } }; @@ -150,8 +149,8 @@ export function collectResourcePlatformEvidence(resource: Resource): string[] { ids.add('kubernetes'); } if ( - resource.docker || - asRecord(platformData?.docker) || + hasDockerFacetEvidence(resource.docker) || + hasDockerFacetEvidence(platformData?.docker) || DOCKER_RESOURCE_TYPES.has(resource.type) ) { ids.add('docker'); @@ -190,21 +189,13 @@ export function buildPrimaryPlatformNavigationVisibility( resource.sources?.includes('availability'), ); return { - proxmox: PRIMARY_PLATFORM_NAV_SCOPE_IDS.proxmox.some((id) => - presentNavigableScopes.has(id), - ), - docker: PRIMARY_PLATFORM_NAV_SCOPE_IDS.docker.some((id) => - presentNavigableScopes.has(id), - ), + proxmox: PRIMARY_PLATFORM_NAV_SCOPE_IDS.proxmox.some((id) => presentNavigableScopes.has(id)), + docker: PRIMARY_PLATFORM_NAV_SCOPE_IDS.docker.some((id) => presentNavigableScopes.has(id)), kubernetes: PRIMARY_PLATFORM_NAV_SCOPE_IDS.kubernetes.some((id) => presentNavigableScopes.has(id), ), - truenas: PRIMARY_PLATFORM_NAV_SCOPE_IDS.truenas.some((id) => - presentNavigableScopes.has(id), - ), - vmware: PRIMARY_PLATFORM_NAV_SCOPE_IDS.vmware.some((id) => - presentNavigableScopes.has(id), - ), + truenas: PRIMARY_PLATFORM_NAV_SCOPE_IDS.truenas.some((id) => presentNavigableScopes.has(id)), + vmware: PRIMARY_PLATFORM_NAV_SCOPE_IDS.vmware.some((id) => presentNavigableScopes.has(id)), standalone: resources.some(isPulseAgentPlatformResource) || hasAvailabilityEndpoints, }; } @@ -232,9 +223,7 @@ export function filterPlatformNavigationShortcuts( ): Record { const routes: Record = {}; for (const [navId, shortcut] of Object.entries(shortcuts)) { - if ( - !primaryPlatformNavigationIsVisible(visibility, navId as PrimaryPlatformNavId) - ) { + if (!primaryPlatformNavigationIsVisible(visibility, navId as PrimaryPlatformNavId)) { continue; } routes[shortcut.key] = shortcut.route; diff --git a/frontend-modern/src/utils/__tests__/agentResources.test.ts b/frontend-modern/src/utils/__tests__/agentResources.test.ts index 44d2ba7c3..ad5e124c7 100644 --- a/frontend-modern/src/utils/__tests__/agentResources.test.ts +++ b/frontend-modern/src/utils/__tests__/agentResources.test.ts @@ -114,7 +114,11 @@ describe('agentResources', () => { makeResource({ type: 'docker-host', identity: { machineId: 'docker-runtime-machine-id' }, - discoveryTarget: { resourceType: 'agent', resourceId: 'resource-agent-id', agentId: 'resource-agent-id' }, + discoveryTarget: { + resourceType: 'agent', + resourceId: 'resource-agent-id', + agentId: 'resource-agent-id', + }, }), ), ).toBe('docker-runtime-machine-id'); @@ -231,6 +235,19 @@ describe('agentResources', () => { }), ), ).toBe(false); + + expect( + hasDockerWorkloadsScope( + makeResource({ + type: 'agent', + docker: {}, + platformData: { + docker: {}, + agent: { hostname: 'tower' }, + }, + }), + ), + ).toBe(false); }); it('builds canonical metrics chart key candidates for host-family resources', () => { diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index eea0d8d86..692ce14d9 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -656,6 +656,41 @@ describe('resourceStateAdapters nodeFromResource', () => { expect((resource.platformData as Record).proxmox).toBeUndefined(); }); + it('does not synthesize Docker platform state from an empty machine-agent Docker facet', () => { + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + id: 'agent-tower', + type: 'agent', + name: 'Tower', + displayName: 'Tower', + platformId: 'tower', + platformType: 'agent', + sourceType: 'hybrid', + status: 'online', + lastSeen: Date.now(), + agent: { + hostname: 'Tower', + }, + docker: {}, + platformData: { + agent: { + hostname: 'Tower', + }, + docker: {}, + }, + } as Resource, + ], + [], + ); + + expect(resource.platformType).toBe('agent'); + expect(resource.sourceType).toBe('agent'); + expect(resource.docker).toBeUndefined(); + expect((resource.platformData as Record).sources).toEqual(['agent']); + expect((resource.platformData as Record).docker).toBeUndefined(); + }); + it('replaces stale platform source facets with the current snapshot sources', () => { const existing = { id: 'agent-tower', diff --git a/frontend-modern/src/utils/agentResources.ts b/frontend-modern/src/utils/agentResources.ts index b4ce14d40..2b8508261 100644 --- a/frontend-modern/src/utils/agentResources.ts +++ b/frontend-modern/src/utils/agentResources.ts @@ -27,6 +27,22 @@ const AGENT_PROVIDER_OWNER_PLATFORM_IDS = new Set([ const asRecord = (value: unknown): Record | undefined => value && typeof value === 'object' ? (value as Record) : undefined; +const hasMeaningfulFacetValue = (value: unknown): boolean => { + if (typeof value === 'string') return value.trim().length > 0; + if (typeof value === 'number') return Number.isFinite(value) && value !== 0; + if (typeof value === 'boolean') return value; + if (Array.isArray(value)) return value.some(hasMeaningfulFacetValue); + + const record = asRecord(value); + if (!record) return false; + return Object.values(record).some(hasMeaningfulFacetValue); +}; + +export const hasDockerFacetEvidence = (value: unknown): boolean => { + const docker = asRecord(value); + return Boolean(docker && hasMeaningfulFacetValue(docker)); +}; + type KubernetesContextLike = { clusterId?: string | null; name?: string | null; @@ -182,13 +198,19 @@ export const getActionableDockerRuntimeIdFromResource = ( (resource.metricsTarget?.resourceType === 'docker-host' ? asTrimmedString(resource.metricsTarget.resourceId) : undefined) || - (resource.type === 'docker-host' ? asTrimmedString(resource.discoveryTarget?.agentId) : undefined) + (resource.type === 'docker-host' + ? asTrimmedString(resource.discoveryTarget?.agentId) + : undefined) ); }; export const hasDockerWorkloadsScope = (resource: Resource): boolean => { const platformData = getPlatformDataRecord(resource); - return resource.type === 'docker-host' || Boolean(asRecord(platformData?.docker)); + return ( + resource.type === 'docker-host' || + hasDockerFacetEvidence(resource.docker) || + hasDockerFacetEvidence(platformData?.docker) + ); }; export const getActionableKubernetesClusterIdFromResource = ( diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 77e48b62a..fc7311dfe 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -24,6 +24,7 @@ import type { Resource } from '@/types/resource'; import { getActionableAgentIdFromResource, getExplicitResourceClusterName, + hasDockerFacetEvidence, } from '@/utils/agentResources'; import { getPreferredInfrastructureDisplayName, @@ -148,6 +149,14 @@ const mergePlatformData = ( continue; } const nested = mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); + if (key === 'docker') { + if (hasDockerFacetEvidence(nested)) { + merged[key] = nested; + } else { + delete merged[key]; + } + continue; + } if (nested) { merged[key] = nested; } @@ -213,7 +222,9 @@ const deriveLegacySourceList = ( if (getFacetRecord(resource, platformData, 'vmware')) sources.push('vmware'); if (getFacetRecord(resource, platformData, 'truenas')) sources.push('truenas'); if (getFacetRecord(resource, platformData, 'kubernetes')) sources.push('kubernetes'); - if (getFacetRecord(resource, platformData, 'docker')) sources.push('docker'); + if (hasDockerFacetEvidence(getFacetRecord(resource, platformData, 'docker'))) { + sources.push('docker'); + } if (getFacetRecord(resource, platformData, 'availability')) sources.push('availability'); if (getFacetRecord(resource, platformData, 'agent')) sources.push('agent'); if (sources.length > 0) { @@ -290,6 +301,9 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD ['availability', resourceRecord.availability], ['physicalDisk', resourceRecord.physicalDisk], ] as const) { + if (key === 'docker' && !hasDockerFacetEvidence(value)) { + continue; + } if (value !== undefined) { normalized[key] = value; } @@ -298,6 +312,10 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } const normalized: JsonRecord = { ...platformData }; + if (asRecord(normalized.docker) && !hasDockerFacetEvidence(normalized.docker)) { + delete normalized.docker; + } + const resourceRecord = getResourceRecord(resource); for (const key of [ 'agent', @@ -312,6 +330,17 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD 'availability', 'physicalDisk', ] as const) { + if ( + key === 'docker' && + !asRecord(normalized[key]) && + hasDockerFacetEvidence(resourceRecord[key]) + ) { + normalized[key] = resourceRecord[key]; + continue; + } + if (key === 'docker') { + continue; + } if (!asRecord(normalized[key]) && asRecord(resourceRecord[key])) { normalized[key] = resourceRecord[key]; } @@ -611,6 +640,11 @@ export const canonicalizeRealtimeResource = ( const platformData = canonicalizeLegacyPlatformData(resource); const platformRecord = asRecord(platformData); const sources = getCanonicalSourceList(resource, platformData); + const docker = hasDockerFacetEvidence(resource.docker) + ? resource.docker + : hasDockerFacetEvidence(platformRecord?.docker) + ? (platformRecord?.docker as Resource['docker']) + : undefined; const platformType = resolvePlatformTypeFromSources(sources) || (hasAvailabilityFacet(resource, platformData) ? 'availability' : resource.platformType); @@ -638,6 +672,7 @@ export const canonicalizeRealtimeResource = ( proxmox: resource.proxmox ?? (platformRecord?.proxmox as Resource['proxmox']), pbs: resource.pbs ?? (platformRecord?.pbs as Resource['pbs']), kubernetes: resource.kubernetes ?? (platformRecord?.kubernetes as Resource['kubernetes']), + docker, vmware: resource.vmware ?? (platformRecord?.vmware as Resource['vmware']), truenas: resource.truenas ?? (platformRecord?.truenas as Resource['truenas']), storage: resource.storage ?? (platformRecord?.storage as Resource['storage']), @@ -722,6 +757,16 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): incomingSources, 'kubernetes', ) as Resource['kubernetes'], + docker: mergeCanonicalSourceFacet( + hasDockerFacetEvidence(incoming.docker) + ? (incoming.docker as JsonRecord | undefined) + : undefined, + hasDockerFacetEvidence(existingCanonical.docker) + ? (existingCanonical.docker as JsonRecord | undefined) + : undefined, + incomingSources, + 'docker', + ) as Resource['docker'], vmware: mergeCanonicalSourceFacet( incoming.vmware as JsonRecord | undefined, existingCanonical.vmware as JsonRecord | undefined, @@ -785,8 +830,7 @@ const buildMemory = ( const cache = asNumber(proxmoxMeta?.memoryCache) ?? asNumber(fallback?.cache) ?? 0; // The metric ships no free bytes for PVE payloads; total-used is the // reclaimable-inclusive available, so carve the cache back out when known. - const free = - metric?.free ?? asNumber(fallback?.free) ?? Math.max(total - used - cache, 0); + const free = metric?.free ?? asNumber(fallback?.free) ?? Math.max(total - used - cache, 0); const usage = metric?.current ?? (total > 0 ? (used / total) * 100 : (asNumber(fallback?.usage) ?? 0)); return {