Add canonical platform scopes for runtime workloads

Teach unified resources to emit platformScopes so runtime workloads can belong to both Docker and their owning platform. Update workload filters to use platform scopes and keep TrueNAS app containers scoped to TrueNAS.
This commit is contained in:
rcourtman 2026-05-20 19:30:55 +01:00
parent 2f77e2680d
commit aabef9afd5
29 changed files with 567 additions and 37 deletions

View file

@ -6934,7 +6934,7 @@
},
{
"id": "platform-support-model-v1",
"summary": "Pulse v6 platform support is governed by one canonical model: `docker` and `kubernetes` are first-class top-level platforms, first-class platforms declare primary ingestion, canonical resource projections, and support-floor surfaces, and runtime variants such as `podman` plus transport helpers are not top-level platforms.",
"summary": "Pulse v6 platform support is governed by one canonical model: owning platform surfaces declare primary ingestion, canonical resource projections, and support-floor surfaces; `docker` is a first-class container-runtime lens and workload surface rather than an exclusive owning platform; `kubernetes` remains a first-class orchestrator platform; runtime variants such as `podman` plus transport helpers are not top-level platforms.",
"kind": "architecture",
"decided_at": "2026-03-30",
"subsystem_ids": [
@ -7192,7 +7192,7 @@
},
{
"id": "platform-page-membership-overlap-v1",
"summary": "Pulse v6 primary IA remains platform-first rather than a generic mixed systems page: a single physical or agent-backed system may appear on multiple platform pages when it participates in multiple platform contracts, and each page must show only the workloads and native resources relevant to that platform. Host identity stays separate from runtime membership, so an Unraid server that reports Docker containers can appear on the container-runtime page while still reading as an Unraid-profiled host rather than a generic Docker server.",
"summary": "Pulse v6 primary IA remains platform-first rather than a generic mixed systems page: a single physical or agent-backed system may appear on multiple platform pages when it participates in multiple platform contracts, and each page must show only the workloads and native resources relevant to that platform. Workload platform membership is a canonical resource contract, not a page-local platformType guess: Docker/Podman containers reported from a Proxmox host or LXC may appear in both the Proxmox Workloads table and the container-runtime lens, while TrueNAS apps that expose Docker-compatible runtime metadata remain TrueNAS-owned and do not become Docker-managed action targets. Host identity stays separate from runtime membership, so an Unraid server that reports Docker containers can appear on the container-runtime page while still reading as an Unraid-profiled host rather than a generic Docker server.",
"kind": "architecture",
"decided_at": "2026-05-17",
"subsystem_ids": [

View file

@ -739,6 +739,12 @@ AI runtime.
so client-side family grouping behaves identically against mock fixtures
and live backends that leave `platformType` empty on a subset of
canonical resource types.
Workload page membership must use the canonical `platformScopes` list when
present instead of treating `platformType` as exclusive ownership. A Docker
or Podman app-container can therefore be visible on both the container
runtime lens and its owning platform page, such as Proxmox when the runtime
is detected inside a PVE LXC, while TrueNAS app containers stay scoped to
TrueNAS even when their runtime metadata uses the shared Docker facet.
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, host-profile labels, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest. The generic `docker` source-platform label is "Docker / Podman" in shared selectors, badges, and filter options so v5 Docker users can find the runtime surface while Podman-backed rows are not mislabeled as Docker-only; "Container runtime" remains the governed platform family, not the primary customer-facing label. Identity colour is semantic, not page-local decoration: shared source/platform badges, host identity badges, and container runtime badges must use the shared presentation helpers so Docker remains on the Docker/Podman blue runtime tone, Podman uses its distinct runtime tone, Proxmox PVE remains orange, and those meanings do not drift across table rows, filters, drawers, or platform pages. Agent host-profile entries, including Unraid, stay in the generated `agentHostProfiles` projection and shared wrapper helpers; frontend primitives may render those labels for Pulse Agent install/identity copy but must not add them to the first-class platform union.
The generated host-profile projection also carries runtime platform fallback
metadata for shared explanation and parity with backend normalization, but

View file

@ -158,6 +158,14 @@ regression protection.
1. Add performance budgets through SLO or contract tests
2. Add query-plan guardrails for DB-backed hot paths
3. Optimize hot paths only when backed by benchmarks or proven query issues
Workload platform-scope filtering and option derivation are part of the
workload table hot path. Shared selectors in
`frontend-modern/src/components/Workloads/workloadSelectors.ts`,
`frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts`,
`frontend-modern/src/components/Workloads/workloadRouteModel.ts`, and
`frontend-modern/src/hooks/useWorkloads.ts` must consume canonical
`platformScopes`; page surfaces must not rebuild platform membership with
ad hoc source or runtime scans.
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
The same rule applies to public setup-script lifecycle routes: `/api/auto-register`
and `/api/auto-unregister` may bypass the global auth wall so their handlers can

View file

@ -114,6 +114,12 @@ Storage/recovery remediation or restore-adjacent workflows may consume
contract. This subsystem must not create a storage-local approval policy,
stale-plan hash, blast-radius model, or execution protocol outside
`internal/api/actions.go` and `internal/actionplanner/planner.go`.
Storage/recovery surfaces may consume unified-resource `platformScopes` as
read-only platform membership context, but they must not reinterpret runtime
scope overlap as storage or recovery ownership. A Docker workload that also
belongs to an owning platform remains governed by the resource, policy, and
backup capability contracts exposed by unified resources and the shared API
boundary.
Successful action plans also remain API-owned audit facts before
storage/recovery surfaces consume them: approval-required plans must persist
as `pending_approval` with initial lifecycle evidence, and retry/idempotency

View file

@ -155,6 +155,14 @@ Swarm capability surfaces.
types, and the helper falls back through the existing source-platform
normalization so client-side grouping behaves identically against mock
fixtures and live backends.
`Resource.PlatformScopes` is the backend-owned platform page membership
contract for unified resources. It is derived by unified resources through
`RefreshPlatformScopes`, transported through `useUnifiedResources`,
`useWorkloads`, and `sourcePlatforms.ts`, and consumed by workload filters
without page-local reinterpretation. `platformType` remains the primary
display/source family; `platformScopes` is the overlap set used when a
runtime workload belongs to both Docker and an owning infrastructure
platform.
17. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
App-container Discovery targets are part of that shared payload contract.
Backend resources must expose frontend-facing `resourceType=app-container`
@ -1830,6 +1838,13 @@ temperature, uptime, container counts, update state, command metadata, and
Swarm local-state/control evidence through `ResourceDockerMeta`; Docker
platform pages must consume those canonical fields rather than keeping
page-local host-runtime shape aliases.
Canonical resources now also carry `platformScopes`, the normalized
platform-page membership list consumed by workload filters. `platformType`
remains the primary display/source family, while `platformScopes` captures
overlap: Docker/Podman resources reported from a Proxmox LXC carry both
`proxmox-pve` and `docker`, but TrueNAS app containers that reuse
`DockerData` for runtime metadata remain scoped to `truenas` and must not be
promoted to Docker-managed action targets.
The same facet bundle now also returns grouped recent-change counts by
canonical change kind, so the detail drawer can surface the distribution of
state transitions, restarts, config updates, and anomalies without

View file

@ -39,6 +39,7 @@ describe('useWorkloadFilterOptions', () => {
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
platformScopes: ['proxmox-pve', 'docker'],
contextLabel: 'docker-prod-a',
dockerHostId: 'docker-prod-a',
containerRuntime: 'docker',
@ -70,6 +71,7 @@ describe('useWorkloadFilterOptions', () => {
string | null
>(null);
const [selectedNode] = createSignal<string | null>(null);
const [platformScope, setPlatformScope] = createSignal('docker');
const { result } = renderHook(() =>
useWorkloadFilterOptions({
@ -77,7 +79,7 @@ describe('useWorkloadFilterOptions', () => {
isWorkloadsRoute: () => false,
allowEmbeddedScopeFilters: () => true,
viewMode,
platformScope: () => 'docker',
platformScope,
containerRuntime,
selectedPlatform,
selectedNode,
@ -95,5 +97,13 @@ describe('useWorkloadFilterOptions', () => {
{ value: 'docker-prod-a', label: 'docker-prod-a' },
]);
expect(result.containerRuntimeOptions()).toEqual(['docker']);
setPlatformScope('proxmox-pve');
expect(result.workloadNodeOptions()).toEqual([
{ value: 'docker-prod-a', label: 'docker-prod-a' },
{ value: 'core-fabric-pve-prod-a', label: 'pve-prod-a' },
]);
expect(result.containerRuntimeOptions()).toEqual(['docker']);
});
});

View file

@ -171,6 +171,7 @@ describe('workloadRouteModel', () => {
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
platformScopes: ['proxmox-pve', 'docker'],
}),
makeGuest({
id: 'pod-a',
@ -183,6 +184,7 @@ describe('workloadRouteModel', () => {
),
).toEqual([
{ value: 'truenas', label: 'TrueNAS' },
{ value: 'proxmox-pve', label: 'PVE' },
{ value: 'docker', label: 'Docker / Podman' },
]);
});

View file

@ -270,23 +270,34 @@ describe('workloadSelectors', () => {
expect(withK8sContext.map((g) => g.name)).toEqual(['k8s-a']);
});
it('filters workloads by canonical platform type', () => {
it('filters workloads by canonical platform scopes', () => {
const guests = [
makeGuest(1, {
name: 'nextcloud',
type: 'app-container',
workloadType: 'app-container',
platformType: 'truenas',
platformScopes: ['truenas'],
containerRuntime: 'docker',
}),
makeGuest(2, {
name: 'grafana',
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
platformScopes: ['proxmox-pve', 'docker'],
containerRuntime: 'docker',
}),
makeGuest(3, {
name: 'vm-101',
type: 'vm',
workloadType: 'vm',
platformType: 'proxmox-pve',
platformScopes: ['proxmox-pve'],
}),
];
const result = filterWorkloads({
const truenasResult = filterWorkloads({
guests,
viewMode: 'app-container',
statusMode: 'all',
@ -297,7 +308,33 @@ describe('workloadSelectors', () => {
selectedKubernetesContext: null,
});
expect(result.map((guest) => guest.name)).toEqual(['nextcloud']);
expect(truenasResult.map((guest) => guest.name)).toEqual(['nextcloud']);
const proxmoxResult = filterWorkloads({
guests,
viewMode: 'app-container',
statusMode: 'all',
searchTerm: '',
selectedNode: null,
selectedHostHint: null,
selectedPlatform: 'proxmox-pve',
selectedKubernetesContext: null,
});
expect(proxmoxResult.map((guest) => guest.name)).toEqual(['grafana']);
const dockerResult = filterWorkloads({
guests,
viewMode: 'app-container',
statusMode: 'all',
searchTerm: '',
selectedNode: null,
selectedHostHint: null,
selectedPlatform: 'docker',
selectedKubernetesContext: null,
});
expect(dockerResult.map((guest) => guest.name)).toEqual(['grafana']);
});
it('filters app containers by Docker host id from related-workload links', () => {

View file

@ -2,6 +2,7 @@ import { createMemo, type Accessor } from 'solid-js';
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import { workloadMatchesPlatformScope } from '@/utils/workloads';
import type { WorkloadsToolbarFilterConfig } from './workloadsFilterModel';
import {
buildWorkloadsContainerRuntimeOptions,
@ -43,9 +44,7 @@ export function useWorkloadFilterOptions(options: WorkloadsWorkloadFilterOptions
}
return options
.allGuests()
.filter(
(guest) => normalizeSourcePlatformQueryValue(guest.platformType || '') === normalizedScope,
);
.filter((guest) => workloadMatchesPlatformScope(guest, normalizedScope));
});
const workloadNodeOptions = createMemo(() => buildWorkloadNodeOptions(platformScopedGuests()));

View file

@ -1,5 +1,6 @@
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import {
getWorkloadPlatformScopes,
normalizeWorkloadViewModeParam,
resolveWorkloadType,
workloadMatchesViewMode,
@ -112,7 +113,7 @@ export const buildWorkloadsPlatformOptions = (
buildSourcePlatformOptions(
guests
.filter((guest) => workloadMatchesViewMode(resolveWorkloadType(guest), viewMode))
.map((guest) => guest.platformType || ''),
.flatMap((guest) => getWorkloadPlatformScopes(guest)),
).map((option) => ({
value: option.key,
label: option.label,

View file

@ -11,6 +11,7 @@ import {
isContainerWorkloadViewMode,
getCanonicalWorkloadId,
resolveWorkloadType,
workloadMatchesPlatformScope,
workloadMatchesViewMode,
} from '@/utils/workloads';
import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation';
@ -62,6 +63,7 @@ const workloadSearchCandidates = (guest: WorkloadGuest): Array<string | number |
guest.clusterName,
guest.instance,
guest.platformType,
guest.platformScopes?.join(' '),
guest.workloadType,
guest.containerRuntime,
guest.containerId,
@ -128,10 +130,8 @@ export const filterWorkloads = ({
}
const normalizedPlatform = normalizeSourcePlatformQueryValue(selectedPlatform);
if (normalizedPlatform) {
guests = guests.filter(
(g) => normalizeSourcePlatformQueryValue(g.platformType || '') === normalizedPlatform,
);
if (normalizedPlatform && normalizedPlatform !== 'all') {
guests = guests.filter((g) => workloadMatchesPlatformScope(g, normalizedPlatform));
}
const normalizedRuntime = (containerRuntime || '').trim().toLowerCase();

View file

@ -452,6 +452,68 @@ describe('useUnifiedResources', () => {
dispose();
});
it('preserves backend platform scopes and falls back to source-derived membership', async () => {
apiFetchMock.mockResolvedValueOnce(
resourceResponse([
{
...v2Resource,
id: 'docker-container-frigate-141',
type: 'app-container',
name: 'frigate',
sources: ['docker'],
platformScopes: ['proxmox-pve', 'docker'],
docker: {
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141',
containerId: 'frigate',
runtime: 'docker',
},
},
{
...v2Resource,
id: 'truenas-app-nextcloud',
type: 'app-container',
name: 'nextcloud',
sources: ['truenas'],
truenas: {
hostname: 'truenas-main',
},
docker: {
containerId: 'nextcloud',
runtime: 'docker',
},
},
]),
);
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources();
});
await waitForValue(
() => Boolean(result!.resources().find((resource) => resource.id === 'docker-container-frigate-141')),
true,
);
const dockerResource = result!.resources().find(
(resource) => resource.id === 'docker-container-frigate-141',
);
const truenasResource = result!.resources().find(
(resource) => resource.id === 'truenas-app-nextcloud',
);
expect(dockerResource?.platformScopes).toEqual(['proxmox-pve', 'docker']);
expect((dockerResource?.platformData as Record<string, unknown>)?.platformScopes).toEqual([
'proxmox-pve',
'docker',
]);
expect(truenasResource?.platformScopes).toEqual(['truenas']);
dispose();
});
it('skips unified resource hydration while disabled and resumes when enabled', async () => {
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;

View file

@ -235,6 +235,22 @@ describe('useWorkloads', () => {
image: 'ix-nextcloud:latest',
},
},
{
...sampleResource,
id: 'docker-container-frigate-141',
type: 'app-container',
name: 'frigate',
status: 'running',
sources: ['docker'],
platformScopes: ['proxmox-pve', 'docker'],
parentName: 'homepage-docker.lab',
docker: {
containerId: 'frigate',
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141',
runtime: 'docker',
image: 'ghcr.io/blakeblackshear/frigate:stable',
},
},
{
...sampleResource,
id: 'vmware-vm-crm',
@ -280,16 +296,20 @@ describe('useWorkloads', () => {
});
await flushAsync();
await waitForWorkloadCount(() => result!.workloads().length, 6);
await waitForWorkloadCount(() => result!.workloads().length, 7);
const byName = new Map(result!.workloads().map((workload) => [workload.name, workload]));
expect(byName.get('vm-101')?.platformType).toBe('proxmox-pve');
expect(byName.get('vm-pbs')?.platformType).toBe('proxmox-pbs');
expect(byName.get('vm-pmg')?.platformType).toBe('proxmox-pmg');
expect(byName.get('nextcloud')?.platformType).toBe('truenas');
expect(byName.get('nextcloud')?.platformScopes).toEqual(['truenas']);
expect(byName.get('nextcloud')?.id).toBe('app-container:truenas-main:nextcloud');
expect(byName.get('nextcloud')?.containerId).toBe('nextcloud-ctr');
expect(byName.get('nextcloud')?.dockerHostId).toBeUndefined();
expect(byName.get('frigate')?.platformType).toBe('docker');
expect(byName.get('frigate')?.platformScopes).toEqual(['proxmox-pve', 'docker']);
expect(byName.get('frigate')?.dockerHostId).toBe('proxmox-lxc-docker:pve-a:node-a:141');
expect(byName.get('crm-app-01')?.platformType).toBe('vmware-vsphere');
expect(byName.get('crm-app-01')?.node).toBe('esxi-01');
expect(byName.get('crm-app-01')?.instance).toBe('Compute-A');

View file

@ -32,6 +32,7 @@ import { getPreferredNormalizedPlatformId } from '@/utils/resourceIdentity';
import { getExplicitResourceClusterName } from '@/utils/agentResources';
import { mergeCanonicalResourceSnapshot } from '@/utils/resourceStateAdapters';
import {
normalizeSourcePlatformScopes,
resolvePlatformTypeFromSources,
resolveSourceTypeFromSources,
} from '@/utils/sourcePlatforms';
@ -47,8 +48,7 @@ const UNIFIED_RESOURCES_CACHE_MAX_AGE_MS = 15_000;
const UNIFIED_RESOURCES_WS_DEBOUNCE_MS = 800;
const UNIFIED_RESOURCES_WS_MIN_REFETCH_INTERVAL_MS = 2_500;
const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200;
const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS =
UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250;
const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS = UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250;
type APIMetricValue = {
value?: number;
@ -140,6 +140,7 @@ type APIResource = {
lastSeen?: string;
parentName?: string;
sources?: string[];
platformScopes?: string[];
sourceStatus?: Record<string, { status: string; lastSeen: string; error?: string }>;
identity?: {
machineId?: string;
@ -636,6 +637,7 @@ const toResource = (v2: APIResource): Resource => {
const platformType =
resolvePlatformTypeFromSources(sources) ||
(resourceType === 'network-endpoint' ? 'availability' : 'agent');
const platformScopes = normalizeSourcePlatformScopes(v2.platformScopes, platformType);
const discoveryResourceType = resolveDiscoveryResourceType(v2.discoveryTarget?.resourceType);
const discoveryAgentId = v2.discoveryTarget?.agentId;
@ -657,6 +659,7 @@ const toResource = (v2: APIResource): Resource => {
displayName: name,
platformId,
platformType,
platformScopes,
sourceType: resolveSourceTypeFromSources(sources),
parentId: v2.parentId,
parentName: v2.parentName,
@ -738,6 +741,7 @@ const toResource = (v2: APIResource): Resource => {
facetCounts: v2.facetCounts,
platformData: {
sources,
platformScopes,
sourceStatus: v2.sourceStatus,
proxmox: v2.proxmox,
agent: v2.agent,
@ -1195,8 +1199,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
}
};
const shouldPreferWsInitialHydration = () =>
prefersWsInitialHydration && !cacheEntry.hasSnapshot;
const shouldPreferWsInitialHydration = () => prefersWsInitialHydration && !cacheEntry.hasSnapshot;
const hasWsInitialHydrationSnapshot = () =>
wsStore.connected() && wsStore.initialDataReceived() && Array.isArray(wsStore.state.resources);
@ -1400,9 +1403,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
scopeVersion += 1;
const nextCacheEntry = seedUnifiedResourcesCacheFromAllResources(
getUnifiedResourcesCacheEntry(
buildScopedUnifiedResourcesCacheKey(cacheKey, nextOrgScope),
),
getUnifiedResourcesCacheEntry(buildScopedUnifiedResourcesCacheKey(cacheKey, nextOrgScope)),
cacheKey,
query,
nextOrgScope,

View file

@ -1,13 +1,11 @@
import {
onCleanup,
createEffect,
createSignal,
type Accessor,
} from 'solid-js';
import { onCleanup, createEffect, createSignal, type Accessor } from 'solid-js';
import { apiFetchJSON, getOrgID } from '@/utils/apiClient';
import { normalizeOrgScope } from '@/utils/orgScope';
import { eventBus } from '@/stores/events';
import { resolvePlatformTypeFromSources } from '@/utils/sourcePlatforms';
import {
normalizeSourcePlatformScopes,
resolvePlatformTypeFromSources,
} from '@/utils/sourcePlatforms';
import { canonicalDiscoveryResourceType } from '@/utils/discoveryTarget';
import { normalizeDiskArray } from '@/utils/format';
import {
@ -59,6 +57,7 @@ type APIResource = {
status?: string;
lastSeen?: string;
sources?: string[];
platformScopes?: string[];
identity?: {
machineId?: string;
hostnames?: string[];
@ -336,6 +335,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
const workloadType = resolveWorkloadType(resource.type);
if (!workloadType) return null;
const platformType = resolvePlatformTypeFromSources(resource.sources);
const platformScopes = normalizeSourcePlatformScopes(resource.platformScopes, platformType);
const dockerManagedAppContainer = isDockerManagedAppContainer({
workloadType,
type: resource.type ?? '',
@ -476,8 +476,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
lock: '',
lastSeen: toIsoString(resource.lastSeen),
isOci: workloadType === 'system-container' ? (resource.proxmox?.isOci ?? false) : false,
osTemplate:
workloadType === 'system-container' ? resource.proxmox?.osTemplate : undefined,
osTemplate: workloadType === 'system-container' ? resource.proxmox?.osTemplate : undefined,
workloadType,
displayId,
containerId: appContainerRuntimeId || undefined,
@ -512,6 +511,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
dockerHostId: dockerManagedAppContainer ? resource.docker?.hostSourceId : undefined,
kubernetesAgentId: workloadType === 'pod' ? resource.kubernetes?.agentId : undefined,
platformType,
platformScopes,
discoveryTarget,
};
};
@ -739,8 +739,7 @@ export function useWorkloads(enabled: Accessor<boolean> = () => true) {
void loadWorkloads(nextOrgScope, {
force: true,
showLoading: nextCacheEntry.workloads.length === 0,
})
.catch(() => undefined);
}).catch(() => undefined);
}
});
onCleanup(unsubscribeOrgSwitch);

View file

@ -726,6 +726,7 @@ export interface Resource {
// Platform/Source
platformId: string;
platformType: PlatformType;
platformScopes?: string[];
sourceType: SourceType;
sources?: string[];

View file

@ -17,6 +17,9 @@ export type WorkloadGuest = (VM | Container) & {
/** Cluster name from Proxmox (for badge display in workloads table). */
clusterName?: string;
platformType?: string;
// Canonical platform-page membership. A runtime workload may belong to more
// than one platform scope, e.g. Docker inside a Proxmox LXC.
platformScopes?: string[];
// For app-container workloads, this is underlying runtime telemetry
// ("docker", "podman", etc.), not the owning platform.
containerRuntime?: string;

View file

@ -284,6 +284,43 @@ describe('resourceStateAdapters nodeFromResource', () => {
});
});
it('preserves canonical platform scopes across realtime resource merges', () => {
const existing = {
id: 'docker-container-frigate-141',
type: 'app-container',
name: 'frigate',
displayName: 'frigate',
platformId: 'frigate',
platformType: 'docker',
platformScopes: ['proxmox-pve', 'docker'],
sourceType: 'api',
sources: ['docker'],
status: 'online',
lastSeen: Date.now() - 1_000,
platformData: {
sources: ['docker'],
docker: {
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141',
containerId: 'frigate',
runtime: 'docker',
},
},
} as Resource;
const [resource] = mergeCanonicalResourceSnapshot(
[
{
...existing,
lastSeen: Date.now(),
platformScopes: undefined,
} as Resource,
],
[existing],
);
expect(resource.platformScopes).toEqual(['proxmox-pve', 'docker']);
});
it('canonicalizes agentless availability realtime resources as availability endpoints', () => {
const [resource] = mergeCanonicalResourceSnapshot(
[

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
getSourcePlatformLabel,
getSourcePlatformPresentation,
normalizeSourcePlatformScopes,
readSourcePlatformFlags,
normalizeSourcePlatformQueryValue,
normalizeSourcePlatformKey,
@ -85,6 +86,20 @@ describe('sourcePlatforms', () => {
});
});
describe('normalizeSourcePlatformScopes', () => {
it('canonicalizes, dedupes, and ignores non-scope tokens', () => {
expect(
normalizeSourcePlatformScopes(['docker', 'pve', 'docker', ' all ', '', null], 'truenas'),
).toEqual(['docker', 'proxmox-pve']);
});
it('falls back to the resolved platform when backend scopes are absent', () => {
expect(normalizeSourcePlatformScopes(undefined, 'truenas')).toEqual(['truenas']);
expect(normalizeSourcePlatformScopes([], 'proxmox')).toEqual(['proxmox-pve']);
expect(normalizeSourcePlatformScopes([], undefined)).toEqual([]);
});
});
describe('readSourcePlatformFlags', () => {
it('tracks canonical source families from mixed source arrays', () => {
expect(readSourcePlatformFlags(['agent', 'pbs', 'docker', 'custom-source'])).toEqual({

View file

@ -31,6 +31,7 @@ import {
getPreferredResourceHostname,
} from '@/utils/resourceIdentity';
import {
normalizeSourcePlatformScopes,
normalizeSourcePlatformKey,
resolvePlatformTypeFromSources,
resolveSourceTypeFromSources,
@ -92,6 +93,10 @@ const readStringArray = (value: unknown): string[] | undefined => {
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
};
const readExplicitPlatformScopes = (resource: Resource): string[] | undefined =>
readStringArray(getResourceRecord(resource).platformScopes) ??
readStringArray(asRecord(resource.platformData)?.platformScopes);
const normalizeSourceToken = (value: string): string =>
normalizeSourcePlatformKey(value) || value.trim().toLowerCase();
@ -546,7 +551,7 @@ const coalesceRealtimeResourceSnapshot = (resources: Resource[]): Resource[] =>
const indexByHostKey = new Map<string, number>();
for (const rawResource of resources) {
const resource = canonicalizeRealtimeResource(rawResource);
const resource = canonicalizeRealtimeResource(rawResource, { synthesizePlatformScopes: false });
const hostKey = getHostResourceMergeKey(resource);
if (!hostKey) {
coalesced.push(resource);
@ -587,18 +592,29 @@ const hasAvailabilityFacet = (
);
};
export const canonicalizeRealtimeResource = (resource: Resource): Resource => {
export const canonicalizeRealtimeResource = (
resource: Resource,
options: { synthesizePlatformScopes?: boolean } = {},
): Resource => {
const platformData = canonicalizeLegacyPlatformData(resource);
const platformRecord = asRecord(platformData);
const sources = getCanonicalSourceList(resource, platformData);
const platformType =
resolvePlatformTypeFromSources(sources) ||
(hasAvailabilityFacet(resource, platformData) ? 'availability' : resource.platformType);
const explicitPlatformScopes = readExplicitPlatformScopes(resource);
const platformScopes =
explicitPlatformScopes !== undefined
? normalizeSourcePlatformScopes(explicitPlatformScopes, platformType)
: options.synthesizePlatformScopes === false
? undefined
: normalizeSourcePlatformScopes(undefined, platformType);
const sourceType =
sources && sources.length > 0 ? resolveSourceTypeFromSources(sources) : resource.sourceType;
const normalizedBase = {
...resource,
platformType,
platformScopes,
sourceType,
platformData,
};
@ -645,7 +661,7 @@ const mergeCanonicalSourceFacet = <T extends JsonRecord>(
export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): Resource => {
if (!existing) {
return incoming;
return canonicalizeRealtimeResource(incoming);
}
const existingCanonical = canonicalizeRealtimeResource(existing);
const incomingSources = getCanonicalSourceList(incoming, incoming.platformData);
@ -653,6 +669,10 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource):
...existingCanonical,
...incoming,
clusterId: incoming.clusterId ?? existingCanonical.clusterId,
platformScopes: normalizeSourcePlatformScopes(
incoming.platformScopes ?? existingCanonical.platformScopes,
incoming.platformType ?? existingCanonical.platformType,
),
discoveryTarget: incoming.discoveryTarget ?? existingCanonical.discoveryTarget,
metricsTarget: incoming.metricsTarget ?? existingCanonical.metricsTarget,
canonicalIdentity: mergeCanonicalIdentity(

View file

@ -79,6 +79,26 @@ export const normalizeSourcePlatformQueryValue = (value: string | null | undefin
return normalizeSourcePlatformKey(normalized) || normalized;
};
export const normalizeSourcePlatformScopes = (
values?: readonly (string | null | undefined)[] | null,
fallbackPlatform?: string | null | undefined,
): string[] => {
const scopes: string[] = [];
const seen = new Set<string>();
const add = (value: string | null | undefined): void => {
const normalized = normalizeSourcePlatformQueryValue(value);
if (!normalized || normalized === 'all' || seen.has(normalized)) return;
seen.add(normalized);
scopes.push(normalized);
};
values?.forEach(add);
if (scopes.length === 0) {
add(fallbackPlatform);
}
return scopes;
};
export const readSourcePlatformFlags = (sources?: string[]): SourcePlatformFlags => {
const flags: SourcePlatformFlags = {
hasAgent: false,

View file

@ -9,6 +9,10 @@ import type { Resource, ResourceDiscoveryTarget } from '@/types/resource';
import type { MetricResourceKind } from '@/utils/metricsKeys';
import { canonicalizeFrontendResourceType } from '@/utils/resourceTypeCompat';
import { canonicalDiscoveryResourceType } from '@/utils/discoveryTarget';
import {
normalizeSourcePlatformQueryValue,
normalizeSourcePlatformScopes,
} from '@/utils/sourcePlatforms';
/**
* Resolve a raw type string (from API or backend) to a semantic WorkloadType.
@ -73,6 +77,19 @@ export const workloadMatchesViewMode = (
return workloadType === viewMode;
};
export const getWorkloadPlatformScopes = (
guest: Pick<WorkloadGuest, 'platformScopes' | 'platformType'>,
): string[] => normalizeSourcePlatformScopes(guest.platformScopes, guest.platformType);
export const workloadMatchesPlatformScope = (
guest: Pick<WorkloadGuest, 'platformScopes' | 'platformType'>,
platformScope?: string | null,
): boolean => {
const normalizedScope = normalizeSourcePlatformQueryValue(platformScope);
if (!normalizedScope || normalizedScope === 'all') return true;
return getWorkloadPlatformScopes(guest).includes(normalizedScope);
};
export const resolveWorkloadType = (
guest: Pick<WorkloadGuest, 'workloadType' | 'type'>,
): WorkloadType => {

View file

@ -1,6 +1,9 @@
package unifiedresources
import "testing"
import (
"reflect"
"testing"
)
func TestRefreshCanonicalIdentityPrefersTargetsAndCanonicalHostData(t *testing.T) {
resource := Resource{
@ -291,6 +294,29 @@ func TestRefreshCanonicalIdentityFeedsPolicyMetadata(t *testing.T) {
}
}
func TestRefreshCanonicalMetadataDerivesRuntimePlatformScopes(t *testing.T) {
resource := Resource{
ID: "docker-container-frigate-141",
Type: ResourceTypeAppContainer,
Name: "frigate",
Sources: []DataSource{SourceDocker},
Docker: &DockerData{
HostSourceID: "proxmox-lxc-docker:pve-a:node-a:141",
ContainerID: "frigate",
Runtime: "docker",
},
}
RefreshCanonicalMetadata(&resource)
if resource.Canonical == nil {
t.Fatalf("expected canonical identity")
}
if got := resource.PlatformScopes; !reflect.DeepEqual(got, []string{"proxmox-pve", "docker"}) {
t.Fatalf("platform scopes = %#v, want proxmox-pve + docker", got)
}
}
func TestRefreshCanonicalIdentityIgnoresProxmoxPoolAsPlatformIdentity(t *testing.T) {
resource := Resource{
ID: "vm-101",

View file

@ -22,6 +22,7 @@ func cloneResource(in *Resource) Resource {
out := *in
out.DiscoveryTarget = cloneDiscoveryTarget(in.DiscoveryTarget)
out.MetricsTarget = cloneMetricsTarget(in.MetricsTarget)
out.PlatformScopes = cloneStringSlice(in.PlatformScopes)
out.Sources = cloneDataSourceSlice(in.Sources)
out.SourceStatus = cloneSourceStatusMap(in.SourceStatus)
out.Identity = cloneResourceIdentity(in.Identity)

View file

@ -0,0 +1,139 @@
package unifiedresources
import (
"sort"
"strings"
)
const proxmoxLXCDockerHostSourcePrefix = "proxmox-lxc-docker:"
var canonicalPlatformScopeOrder = []string{
"agent",
"truenas",
"proxmox-pve",
"proxmox-pbs",
"proxmox-pmg",
"docker",
"kubernetes",
"vmware-vsphere",
"availability",
}
// RefreshPlatformScopes derives the canonical platform-page membership for a
// resource. Platform scopes are intentionally separate from the primary display
// platform: runtime resources such as Docker containers can belong to both the
// runtime lens and the platform that owns the host/guest they run on.
func RefreshPlatformScopes(resource *Resource) {
if resource == nil {
return
}
scopes := make(map[string]struct{}, len(resource.Sources)+2)
for _, source := range resource.Sources {
addPlatformScope(scopes, platformScopeForSource(source))
}
addPlatformScopesForFacets(scopes, *resource)
if shouldAddDockerPlatformScope(*resource) {
addPlatformScope(scopes, "docker")
}
if resource.Docker != nil {
hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID)
if strings.HasPrefix(hostSourceID, proxmoxLXCDockerHostSourcePrefix) {
addPlatformScope(scopes, "proxmox-pve")
addPlatformScope(scopes, "docker")
}
}
resource.PlatformScopes = orderPlatformScopes(scopes)
}
func platformScopeForSource(source DataSource) string {
switch source {
case SourceAgent:
return "agent"
case SourceProxmox:
return "proxmox-pve"
case SourceDocker:
return "docker"
case SourcePBS:
return "proxmox-pbs"
case SourcePMG:
return "proxmox-pmg"
case SourceK8s:
return "kubernetes"
case SourceTrueNAS:
return "truenas"
case SourceVMware:
return "vmware-vsphere"
case SourceAvailability:
return "availability"
default:
return ""
}
}
func addPlatformScopesForFacets(scopes map[string]struct{}, resource Resource) {
if resource.Agent != nil {
addPlatformScope(scopes, "agent")
}
if resource.TrueNAS != nil {
addPlatformScope(scopes, "truenas")
}
if resource.Proxmox != nil {
addPlatformScope(scopes, "proxmox-pve")
}
if resource.PBS != nil {
addPlatformScope(scopes, "proxmox-pbs")
}
if resource.PMG != nil {
addPlatformScope(scopes, "proxmox-pmg")
}
if resource.Kubernetes != nil {
addPlatformScope(scopes, "kubernetes")
}
if resource.VMware != nil {
addPlatformScope(scopes, "vmware-vsphere")
}
if resource.Availability != nil || CanonicalResourceType(resource.Type) == ResourceTypeNetworkEndpoint {
addPlatformScope(scopes, "availability")
}
}
func shouldAddDockerPlatformScope(resource Resource) bool {
if resource.Docker == nil {
return false
}
if resource.TrueNAS != nil || hasDataSource(resource.Sources, SourceTrueNAS) {
return false
}
return true
}
func addPlatformScope(scopes map[string]struct{}, scope string) {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return
}
scopes[scope] = struct{}{}
}
func orderPlatformScopes(scopes map[string]struct{}) []string {
if len(scopes) == 0 {
return nil
}
out := make([]string, 0, len(scopes))
for _, scope := range canonicalPlatformScopeOrder {
if _, ok := scopes[scope]; ok {
out = append(out, scope)
delete(scopes, scope)
}
}
unknownStart := len(out)
for scope := range scopes {
out = append(out, scope)
}
sort.Strings(out[unknownStart:])
return out
}

View file

@ -0,0 +1,67 @@
package unifiedresources
import "testing"
func TestRefreshPlatformScopes_DerivesSourceMembership(t *testing.T) {
resource := Resource{
ID: "node-1",
Type: ResourceTypeAgent,
Name: "pve-node",
Sources: []DataSource{SourceAgent, SourceProxmox},
Agent: &AgentData{Hostname: "pve-node"},
Proxmox: &ProxmoxData{NodeName: "pve-node"},
}
RefreshPlatformScopes(&resource)
assertStringSliceEqual(t, resource.PlatformScopes, []string{"agent", "proxmox-pve"})
}
func TestRefreshPlatformScopes_ProxmoxLXCDockerRuntimeBelongsToRuntimeAndOwningPlatform(t *testing.T) {
resource := Resource{
ID: "docker-container-frigate-141",
Type: ResourceTypeAppContainer,
Name: "frigate",
Sources: []DataSource{SourceDocker},
Docker: &DockerData{
HostSourceID: "proxmox-lxc-docker:pve-a:node-a:141",
ContainerID: "frigate",
Runtime: "docker",
},
}
RefreshPlatformScopes(&resource)
assertStringSliceEqual(t, resource.PlatformScopes, []string{"proxmox-pve", "docker"})
}
func TestRefreshPlatformScopes_TrueNASAppContainerKeepsOwningPlatform(t *testing.T) {
resource := Resource{
ID: "app-container:truenas-main:nextcloud",
Type: ResourceTypeAppContainer,
Name: "nextcloud",
Sources: []DataSource{SourceTrueNAS},
TrueNAS: &TrueNASData{Hostname: "truenas-main"},
Docker: &DockerData{
ContainerID: "nextcloud",
Image: "ix-nextcloud:latest",
Runtime: "docker",
},
}
RefreshPlatformScopes(&resource)
assertStringSliceEqual(t, resource.PlatformScopes, []string{"truenas"})
}
func assertStringSliceEqual(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("slice length = %d, want %d; got %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got)
}
}
}

View file

@ -93,6 +93,7 @@ func RefreshCanonicalMetadata(resource *Resource) {
}
RefreshCanonicalIdentity(resource)
RefreshPlatformScopes(resource)
RefreshPolicyMetadata(resource)
}

View file

@ -404,6 +404,19 @@ func TestResourceRegistry_IngestResourcesSeedsHostScopedDockerContainerMappings(
assertSourceTarget("docker-container-frigate-105", host105+"/container/frigate")
assertSourceTarget("docker-container-frigate-141", host141+"/container/frigate")
scopesByHost := make(map[string][]string, len(resources))
for _, resource := range resources {
if resource.Docker == nil {
t.Fatalf("docker container resource missing Docker payload: %#v", resource)
}
scopesByHost[resource.Docker.HostSourceID] = resource.PlatformScopes
}
for _, host := range []string{host105, host141} {
if got := scopesByHost[host]; !reflect.DeepEqual(got, []string{"proxmox-pve", "docker"}) {
t.Fatalf("host %q platform scopes = %#v, want proxmox-pve + docker", host, got)
}
}
}
func TestResourceRegistry_IngestResourcesDerivesClusterWorkloadParentFromSeededProxmoxNode(t *testing.T) {
@ -3724,6 +3737,9 @@ func TestRegistryIngestKeepsDockerContainersScopedToTheirHost(t *testing.T) {
if resource.Name != "frigate" {
t.Fatalf("unexpected container name: %q", resource.Name)
}
if !reflect.DeepEqual(resource.PlatformScopes, []string{"proxmox-pve", "docker"}) {
t.Fatalf("platform scopes = %#v, want proxmox-pve + docker", resource.PlatformScopes)
}
hostsSeen[strings.TrimSpace(resource.Docker.HostSourceID)] = struct{}{}
}
for _, want := range []string{

View file

@ -23,6 +23,7 @@ type Resource struct {
Canonical *CanonicalIdentity `json:"canonicalIdentity,omitempty"`
Policy *ResourcePolicy `json:"policy,omitempty"`
AISafeSummary string `json:"aiSafeSummary,omitempty"`
PlatformScopes []string `json:"platformScopes,omitempty"`
Sources []DataSource `json:"sources"`
SourceStatus map[DataSource]SourceStatus `json:"sourceStatus,omitempty"`