mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
2f77e2680d
commit
aabef9afd5
29 changed files with 567 additions and 37 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ export interface Resource {
|
|||
// Platform/Source
|
||||
platformId: string;
|
||||
platformType: PlatformType;
|
||||
platformScopes?: string[];
|
||||
sourceType: SourceType;
|
||||
sources?: string[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
139
internal/unifiedresources/platform_scopes.go
Normal file
139
internal/unifiedresources/platform_scopes.go
Normal 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
|
||||
}
|
||||
67
internal/unifiedresources/platform_scopes_test.go
Normal file
67
internal/unifiedresources/platform_scopes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,7 @@ func RefreshCanonicalMetadata(resource *Resource) {
|
|||
}
|
||||
|
||||
RefreshCanonicalIdentity(resource)
|
||||
RefreshPlatformScopes(resource)
|
||||
RefreshPolicyMetadata(resource)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue