mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
64f0711f51
commit
ebd61ee109
9 changed files with 163 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue