Fix empty Docker facet tab admission

Refs #1518
This commit is contained in:
rcourtman 2026-07-06 14:48:07 +01:00
parent 64f0711f51
commit ebd61ee109
9 changed files with 163 additions and 29 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PrimaryPlatformNavId, readonly string[]> = {
proxmox: ['proxmox-pve', 'proxmox-pbs', 'proxmox-pmg'],
docker: ['docker'],
kubernetes: ['kubernetes'],
@ -100,6 +97,8 @@ const addDirectDockerRuntimeEvidence = (
platformData: Record<string, unknown> | 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<string, string> {
const routes: Record<string, string> = {};
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;

View file

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

View file

@ -656,6 +656,41 @@ describe('resourceStateAdapters nodeFromResource', () => {
expect((resource.platformData as Record<string, unknown>).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<string, unknown>).sources).toEqual(['agent']);
expect((resource.platformData as Record<string, unknown>).docker).toBeUndefined();
});
it('replaces stale platform source facets with the current snapshot sources', () => {
const existing = {
id: 'agent-tower',

View file

@ -27,6 +27,22 @@ const AGENT_PROVIDER_OWNER_PLATFORM_IDS = new Set<string>([
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' ? (value as Record<string, unknown>) : 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 = (

View file

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