diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 68ffebef1e..ebd8a048c8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -828,10 +828,17 @@ not a replacement status card, CTA band, or page-local nested card. 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. + or Podman app-container can therefore carry both the container runtime lens + and its owning platform page in routing/filter context, 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. That membership overlap is not permission to duplicate the + detailed container table into every platform overview: Proxmox Overview + keeps the default Workloads peer table to VMs and LXCs, and Docker-in-LXC + evidence belongs as LXC drawer detail while `/docker` + remains the canonical detailed Docker / Podman container lens. The overview + table should not add peer rows, badges, or child rows for Docker containers; + those signals compete with VM/LXC state and belong one click down. Primary navigation uses that same membership model: the Docker route is the container-runtime lens and may be labelled `Containers` in the shell, while shared source badges, filters, and runtime management copy continue diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index c4d5071a45..dd06f4073d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -124,6 +124,8 @@ regression protection. 102. `frontend-modern/src/useAppRuntimeState.ts` 103. `frontend-modern/src/utils/storageSummaryCache.ts` 104. `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx` +105. `frontend-modern/src/components/Workloads/nestedWorkloadContext.ts` +106. `frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts` ## Shared Boundaries @@ -154,6 +156,17 @@ regression protection. `frontend-modern/src/hooks/useWorkloads.ts` must consume canonical `platformScopes`; page surfaces must not rebuild platform membership with ad hoc source or runtime scans. + Page-owned workload-type exclusions are part of that same hot path: + `useWorkloadsState` must apply them before dedupe, filter-option + derivation, summary statistics, and row rendering so a platform overview + that demotes nested workloads does not carry hidden rows through downstream + table state. + When a platform page still needs a visible signal for those demoted nested + workloads, `nestedWorkloadContext.ts` must derive a bounded summary from the + same raw workload snapshot before row rendering. That derivation must use + canonical workload IDs and ambiguity-safe runtime-host matching; row and + drawer components may render the summary they are given, but they must not + scan hidden workloads per row or guess ownership from ambiguous VMIDs. The same `useWorkloads.ts` mapping owns canonical-field fallback for per-row scalars the workload table reads. `WorkloadGuest.uptime` must fall back to the canonical `Resource.Uptime` field after the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c94edb8689..cebcb27820 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -141,6 +141,11 @@ dense, and backed by the canonical guest `LastBackup` synchronization from PVE storage and PBS evidence; the Proxmox Backups tab is the drilldown for coverage, restore-point, and source-detail evidence, not the first place users must visit to learn whether a guest is backed up. +That overview Backup signal belongs to Proxmox VMs and LXCs. If the embedded +Workloads table demotes Docker-in-LXC `app-container` rows out of peer +membership, the backup surface must still receive the page model's Proxmox +guest inventory for VM/LXC coverage and must not reinterpret Docker runtime +membership as backup or recovery ownership. The platform page embedding point may pass read-only Proxmox guest inventory into that backup surface solely for workload identity correlation; the backup surface must still source restore evidence from the PVE/PBS backup APIs rather diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 01e29d2217..c03fbcc723 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2282,6 +2282,15 @@ 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. +That overlap is not default peer-row membership on every platform overview. +The Docker / Podman runtime lens is the canonical detailed table for Docker +containers, while the Proxmox Overview workload table stays focused on VMs and +LXCs. Proxmox may surface Docker-inside-LXC evidence as LXC drawer detail, but +it must not promote those `app-container` rows into the default Proxmox peer +workload table or add always-visible child rows that compete with VM/LXC scan +flow. That nested context must be read-only and keyed by the same canonical +Proxmox guest identity and Docker host source identity used by the resource +model; ambiguous runtime-host matches must be omitted rather than guessed. 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 diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index fbf079587d..d73224562b 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -571,6 +571,38 @@ describe('GuestDrawer', () => { }); }); + describe('Nested workload context card', () => { + it('shows nested Docker container count, sample rows, and canonical Docker link', () => { + render(() => ( + + )); + + const card = screen.getByTestId('nested-workload-context-card'); + expect(within(card).getByText('Nested Docker')).toBeInTheDocument(); + expect(within(card).getByText('Containers')).toBeInTheDocument(); + expect(within(card).getByText('2')).toBeInTheDocument(); + expect(within(card).getByText('frigate')).toBeInTheDocument(); + expect(within(card).getByText('mosquitto')).toBeInTheDocument(); + expect( + within(card).getByRole('link', { name: 'Open Docker page for media-lxc' }), + ).toHaveAttribute('href', '/docker'); + }); + }); + // ── Agent info ── describe('Agent info', () => { diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index a623f91c41..712e2f0699 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -146,6 +146,7 @@ export const GuestDrawer: Component = (props) => { hasOsInfo={hasOsInfo()} ipAddresses={ipAddresses()} networkInterfaces={networkInterfaces()} + nestedWorkloadContext={props.nestedWorkloadContext} normalizedTags={normalizedTags()} onCustomUrlChange={props.onCustomUrlChange} customUrl={props.customUrl} diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 2f1a0d63c6..83bf9b6fb6 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -1,5 +1,6 @@ import { For, Show } from 'solid-js'; import { TerminalSquare } from 'lucide-solid'; +import BoxIcon from 'lucide-solid/icons/box'; import { formatDiscoveryAge } from '@/api/discovery'; import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrastructureWorkspaceModel'; @@ -13,6 +14,7 @@ import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { DiskList } from './DiskList'; import { getGuestDrawerMemoryRows, isGuestDrawerVM } from './guestDrawerModel'; +import type { NestedWorkloadContext } from './nestedWorkloadContext'; import { IN_GUEST_AGENT_INSTALL_ACTION_LABEL, IN_GUEST_AGENT_INSTALL_TITLE, @@ -35,6 +37,7 @@ interface GuestDrawerOverviewProps { showInGuestAgentInstallCue: boolean; ipAddresses: string[]; networkInterfaces: NonNullable; + nestedWorkloadContext?: NestedWorkloadContext; normalizedTags: string[]; onCustomUrlChange?: GuestDrawerProps['onCustomUrlChange']; customUrl?: GuestDrawerProps['customUrl']; @@ -215,6 +218,54 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { + + {(context) => { + const visibleItems = () => context().items.slice(0, 4); + const hiddenCount = () => Math.max(0, context().count - visibleItems().length); + return ( +
+
+

+ {context().title} +

+ + +
+
+
+ Containers + {context().count} +
+ + {(item) => ( +
+ + {item.name} + + + {item.status} + +
+ )} +
+ 0}> +
+{hiddenCount()} more
+
+
+
+ ); + }} +
+ {/* vSphere placement card: vCenter / Datacenter / Cluster live on WorkloadGuest.vmware and aren't surfaced by System (Node already shows the runtime host). Render only when the workload is a @@ -324,7 +375,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { {(row) => (
{row.label} - + {row.value}
diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx index df0f44a1a9..20e6638b19 100644 --- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx @@ -56,6 +56,7 @@ type WorkloadPanelProps = Pick< | 'focusedSummaryWorkloadGroupId' | 'hoveredSummaryWorkloadGroupScope' | 'isMobile' + | 'nestedWorkloadContextByGuestId' | 'nodeByInstance' | 'search' | 'selectedGuestId' @@ -519,6 +520,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) { const pn = parentNode(); return pn ? isNodeOnline(pn) : true; }; + const nestedWorkloadContext = () => + props.nestedWorkloadContextByGuestId()[guestId()]; return ( @@ -564,6 +567,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) { guest={guest()} onClose={() => props.setSelectedGuestId(null)} customUrl={metadata()?.customUrl} + nestedWorkloadContext={nestedWorkloadContext()} onCustomUrlChange={props.handleCustomUrlUpdate} parentNodeOnline={parentNodeOnline()} /> diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index 9a52028554..5ed53d042f 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -3,10 +3,7 @@ import { Show } from 'solid-js'; import { EmptyState } from '@/components/shared/EmptyState'; import { TableCard } from '@/components/shared/TableCard'; import { WorkloadsFilter } from './WorkloadsFilter'; -import { - DEFAULT_WORKLOADS_VIEW_MODE, - hasActiveWorkloadsFilters, -} from './workloadsFilterModel'; +import { DEFAULT_WORKLOADS_VIEW_MODE, hasActiveWorkloadsFilters } from './workloadsFilterModel'; import { WorkloadsTable } from './WorkloadsTable'; import { useWorkloadsState, @@ -124,6 +121,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) { focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId} hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope} isMobile={state.isMobile} + nestedWorkloadContextByGuestId={state.nestedWorkloadContextByGuestId} nodeByInstance={state.nodeByInstance} search={state.search} selectedGuestId={state.selectedGuestId} diff --git a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx index dd03ea9f66..b86ae63da4 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx @@ -32,6 +32,7 @@ type WorkloadsTableProps = Pick< | 'focusedSummaryWorkloadGroupId' | 'hoveredSummaryWorkloadGroupScope' | 'isMobile' + | 'nestedWorkloadContextByGuestId' | 'nodeByInstance' | 'search' | 'selectedGuestId' @@ -116,6 +117,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) { focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId} hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope} isMobile={props.isMobile} + nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId} nodeByInstance={props.nodeByInstance} search={props.search} selectedGuestId={props.selectedGuestId} diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx index 6926fbe669..16e580cef0 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx @@ -233,6 +233,55 @@ describe('Workloads pod workloads integration', () => { expect(getByTestId('workloads-filter')).toHaveTextContent('host-filter-enabled'); }); + it('excludes app-container workloads when a platform page owns them as nested context', async () => { + mockLocationSearch = '?type=all'; + mockWorkloads = [ + { + id: 'pve-lxc-101', + vmid: 101, + name: 'media-lxc', + node: 'pve-a', + instance: 'pve-a', + status: 'running', + type: 'system-container', + workloadType: 'system-container', + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], + }, + { + id: 'app-container:pve-a:grafana', + vmid: 0, + name: 'grafana', + node: 'media-lxc', + instance: 'pve-a', + status: 'running', + type: 'app-container', + workloadType: 'app-container', + platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], + containerRuntime: 'docker', + }, + ]; + + render(() => ( + + )); + + await waitFor(() => { + expect(screen.getByText('media-lxc')).toBeInTheDocument(); + }); + + expect(screen.queryByText('grafana')).not.toBeInTheDocument(); + }); + it('uses filtered-empty copy for table-only surfaces when filters remove every workload', () => { render(() => ( ({ })); vi.mock('../GuestDrawer', () => ({ - GuestDrawer: () =>
drawer
, + GuestDrawer: (props: { nestedWorkloadContext?: { label: string; count: number } }) => ( +
+ drawer + + {props.nestedWorkloadContext + ? `${props.nestedWorkloadContext.label} ${props.nestedWorkloadContext.count}` + : ''} + +
+ ), })); vi.mock('../NodeDrawer', () => ({ @@ -836,6 +845,55 @@ describe('Workloads performance contract', () => { expect(canonicalId).toBe('shared:node-x:42'); }); + it('applies page-owned workload type exclusions before derived row state', async () => { + mockLocationSearch = '?type=all'; + mockWorkloads = [ + makeGuest(1, { + name: 'pve-lxc', + type: 'lxc', + workloadType: 'system-container', + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], + }), + makeGuest(2, { + name: 'nested-docker-container', + type: 'app-container', + workloadType: 'app-container', + platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], + containerRuntime: 'docker', + dockerHostId: 'proxmox-lxc-docker:cluster-1:node-1:101', + }), + ]; + + const { container } = render(() => ( + + )); + + await waitFor(() => { + expect(container.querySelector('[data-testid="guest-row-pve-lxc"]')).toBeInTheDocument(); + }); + + expect(getGuestRowCount(container)).toBe(1); + expect( + container.querySelector('[data-testid="guest-row-nested-docker-container"]'), + ).toBeNull(); + expect(screen.queryByTestId('nested-workload-context-row')).toBeNull(); + + fireEvent.click(screen.getByTestId('guest-row-toggle-pve-lxc')); + expect(await screen.findByTestId('guest-drawer')).toBeInTheDocument(); + expect(screen.getByTestId('drawer-nested-context')).toHaveTextContent('Docker 1'); + }); + it('routes org scope normalization through the shared helper', () => { expect(workloadsSource).toContain('useWorkloadsState'); expect(workloadsStateSource).toContain('useWorkloadGuestMetadataState'); diff --git a/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts b/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts new file mode 100644 index 0000000000..3c74ab6c0f --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkloadGuest } from '@/types/workloads'; +import { buildNestedWorkloadContextByGuestId } from '../nestedWorkloadContext'; + +const makeGuest = (overrides: Partial): WorkloadGuest => + ({ + id: 'guest-1', + vmid: 101, + name: 'guest-1', + node: 'node-a', + instance: 'pve-a', + status: 'running', + type: 'lxc', + workloadType: 'system-container', + cpu: 0, + cpus: 1, + memory: { total: 0, used: 0, free: 0, usage: 0 }, + disk: { total: 0, used: 0, free: 0, usage: 0 }, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 0, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: new Date().toISOString(), + ...overrides, + }) as WorkloadGuest; + +describe('nested workload context', () => { + it('maps Proxmox LXC Docker app containers to the owning visible LXC', () => { + const lxc = makeGuest({ + id: 'lxc-141', + vmid: 141, + name: 'media-lxc', + node: 'node-a', + instance: 'pve-a', + platformScopes: ['proxmox-pve'], + }); + const container = makeGuest({ + id: 'app-container:frigate', + vmid: 0, + name: 'frigate', + type: 'app-container', + workloadType: 'app-container', + platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], + containerRuntime: 'docker', + dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141', + image: 'ghcr.io/blakeblackshear/frigate:stable', + }); + + const result = buildNestedWorkloadContextByGuestId({ + guests: [lxc, container], + visibleGuests: [lxc], + excludedWorkloadTypes: ['app-container'], + platformScope: 'proxmox-pve', + }); + + expect(result['pve-a:node-a:141']).toMatchObject({ + label: 'Docker', + count: 1, + href: '/docker', + items: [{ name: 'frigate', status: 'running' }], + }); + }); + + it('does not guess when Docker host identity only matches ambiguous VMIDs', () => { + const firstLxc = makeGuest({ + id: 'lxc-a-141', + vmid: 141, + name: 'first-lxc', + node: 'node-a', + instance: 'pve-a', + }); + const secondLxc = makeGuest({ + id: 'lxc-b-141', + vmid: 141, + name: 'second-lxc', + node: 'node-b', + instance: 'pve-b', + }); + const container = makeGuest({ + id: 'app-container:ambiguous', + vmid: 0, + name: 'ambiguous', + type: 'app-container', + workloadType: 'app-container', + platformScopes: ['proxmox-pve', 'docker'], + dockerHostId: 'proxmox-lxc-docker:141', + }); + + const result = buildNestedWorkloadContextByGuestId({ + guests: [firstLxc, secondLxc, container], + visibleGuests: [firstLxc, secondLxc], + excludedWorkloadTypes: ['app-container'], + platformScope: 'proxmox-pve', + }); + + expect(result).toEqual({}); + }); + + it('only builds summaries for workload types the page deliberately excludes', () => { + const lxc = makeGuest({ vmid: 141, name: 'media-lxc' }); + const container = makeGuest({ + id: 'app-container:frigate', + vmid: 0, + name: 'frigate', + type: 'app-container', + workloadType: 'app-container', + platformScopes: ['proxmox-pve', 'docker'], + dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141', + }); + + const result = buildNestedWorkloadContextByGuestId({ + guests: [lxc, container], + visibleGuests: [lxc], + excludedWorkloadTypes: [], + platformScope: 'proxmox-pve', + }); + + expect(result).toEqual({}); + }); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index e58eb1048b..b8258a0b3f 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -8,6 +8,7 @@ import type { import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel'; import { formatBytes, formatPercent } from '@/utils/format'; import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads'; +import type { NestedWorkloadContext } from './nestedWorkloadContext'; type Guest = WorkloadGuest; @@ -17,6 +18,7 @@ export interface GuestDrawerProps { customUrl?: string; onCustomUrlChange?: (guestId: string, url: string) => void; parentNodeOnline?: boolean; + nestedWorkloadContext?: NestedWorkloadContext; } export type GuestDrawerTab = 'overview' | 'history' | 'discovery'; @@ -301,7 +303,10 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] = const cache = memory.cache ?? 0; if (total > 0) { - rows.push({ label: 'Usage', value: `${formatPercent((used / total) * 100)} · ${formatBytes(used)}` }); + rows.push({ + label: 'Usage', + value: `${formatPercent((used / total) * 100)} · ${formatBytes(used)}`, + }); rows.push({ label: 'Total', value: formatBytes(total) }); if (cache > 0) { rows.push({ label: 'Reclaimable cache', value: formatBytes(cache) }); diff --git a/frontend-modern/src/components/Workloads/nestedWorkloadContext.ts b/frontend-modern/src/components/Workloads/nestedWorkloadContext.ts new file mode 100644 index 0000000000..d2336e62b9 --- /dev/null +++ b/frontend-modern/src/components/Workloads/nestedWorkloadContext.ts @@ -0,0 +1,180 @@ +import type { WorkloadGuest, WorkloadType } from '@/types/workloads'; +import { + getCanonicalWorkloadId, + resolveWorkloadType, + workloadMatchesPlatformScope, +} from '@/utils/workloads'; + +const LXC_DOCKER_HOST_PREFIX = 'proxmox-lxc-docker:'; +const NESTED_WORKLOAD_DETAILS_PATH = '/docker'; + +export interface NestedWorkloadContextItem { + id: string; + name: string; + status: string; + runtimeLabel: string; +} + +export interface NestedWorkloadContext { + type: 'app-container'; + label: string; + title: string; + count: number; + href: string; + items: NestedWorkloadContextItem[]; +} + +export type NestedWorkloadContextByGuestId = Record; + +interface BuildNestedWorkloadContextParams { + guests: readonly WorkloadGuest[]; + visibleGuests: readonly WorkloadGuest[]; + excludedWorkloadTypes?: readonly WorkloadType[]; + platformScope?: string | null; +} + +const cleanText = (value?: string | number | null): string => String(value ?? '').trim(); + +const normalizeCandidate = (value?: string | number | null): string => + cleanText(value).toLowerCase(); + +const addCandidate = (target: Set, value?: string | number | null): void => { + const normalized = normalizeCandidate(value); + if (normalized) target.add(normalized); +}; + +const addHostnameCandidates = (target: Set, value?: string | null): void => { + const normalized = normalizeCandidate(value); + if (!normalized) return; + target.add(normalized); + const shortName = normalized.split('.')[0]; + if (shortName) target.add(shortName); +}; + +const buildNodeScopedIdentityCandidates = (guest: WorkloadGuest): Set => { + const candidates = new Set(); + const instance = cleanText(guest.instance); + const node = cleanText(guest.node); + const vmid = Number.isFinite(guest.vmid) ? String(Number(guest.vmid)) : ''; + + addCandidate(candidates, getCanonicalWorkloadId(guest)); + addCandidate(candidates, guest.id); + addCandidate(candidates, guest.displayId); + addHostnameCandidates(candidates, guest.name); + + if (instance && node && vmid) addCandidate(candidates, `${instance}:${node}:${vmid}`); + if (instance && vmid) addCandidate(candidates, `${instance}:${vmid}`); + if (node && vmid) addCandidate(candidates, `${node}:${vmid}`); + if (vmid) addCandidate(candidates, vmid); + + return candidates; +}; + +const addDockerHostIdentitySegments = (target: Set, value?: string | null): void => { + const normalized = normalizeCandidate(value); + if (!normalized) return; + + addCandidate(target, normalized); + const withoutPrefix = normalized.startsWith(LXC_DOCKER_HOST_PREFIX) + ? normalized.slice(LXC_DOCKER_HOST_PREFIX.length) + : normalized; + addCandidate(target, withoutPrefix); + + const segments = withoutPrefix.split(':').filter(Boolean); + if (segments.length >= 3) addCandidate(target, segments.slice(-3).join(':')); + if (segments.length >= 2) addCandidate(target, segments.slice(-2).join(':')); + if (segments.length >= 1) addCandidate(target, segments[segments.length - 1]); +}; + +const buildAppContainerParentCandidates = (guest: WorkloadGuest): Set => { + const candidates = new Set(); + addDockerHostIdentitySegments(candidates, guest.dockerHostId); + addHostnameCandidates(candidates, guest.contextLabel); + addHostnameCandidates(candidates, guest.node); + addHostnameCandidates(candidates, guest.instance); + return candidates; +}; + +const formatRuntimeLabel = (runtime?: string | null): string => { + const normalized = normalizeCandidate(runtime); + if (normalized === 'podman') return 'Podman'; + return 'Docker'; +}; + +const formatStatusLabel = (status?: string | null): string => cleanText(status) || 'unknown'; + +const createNestedItem = (guest: WorkloadGuest): NestedWorkloadContextItem => ({ + id: getCanonicalWorkloadId(guest), + name: cleanText(guest.name) || cleanText(guest.containerId) || getCanonicalWorkloadId(guest), + status: formatStatusLabel(guest.status), + runtimeLabel: formatRuntimeLabel(guest.containerRuntime), +}); + +const chooseContextLabel = (items: readonly NestedWorkloadContextItem[]): string => { + const labels = Array.from(new Set(items.map((item) => item.runtimeLabel))); + if (labels.length === 1) return labels[0]; + return 'Containers'; +}; + +export const buildNestedWorkloadContextByGuestId = ({ + guests, + visibleGuests, + excludedWorkloadTypes, + platformScope, +}: BuildNestedWorkloadContextParams): NestedWorkloadContextByGuestId => { + const excludedTypes = new Set(excludedWorkloadTypes ?? []); + if (!excludedTypes.has('app-container')) return {}; + + const parentKeyOwners = new Map(); + const parentById = new Map(); + for (const guest of visibleGuests) { + const type = resolveWorkloadType(guest); + if (type !== 'vm' && type !== 'system-container') continue; + + const parentId = getCanonicalWorkloadId(guest); + parentById.set(parentId, guest); + for (const candidate of buildNodeScopedIdentityCandidates(guest)) { + const existing = parentKeyOwners.get(candidate); + parentKeyOwners.set( + candidate, + existing === undefined || existing === parentId ? parentId : null, + ); + } + } + + const itemsByParentId = new Map(); + for (const guest of guests) { + if (resolveWorkloadType(guest) !== 'app-container') continue; + if (!workloadMatchesPlatformScope(guest, platformScope)) continue; + + let parentId: string | null = null; + for (const candidate of buildAppContainerParentCandidates(guest)) { + const owner = parentKeyOwners.get(candidate); + if (owner) { + parentId = owner; + break; + } + } + if (!parentId || !parentById.has(parentId)) continue; + + const items = itemsByParentId.get(parentId) ?? []; + items.push(createNestedItem(guest)); + itemsByParentId.set(parentId, items); + } + + const contexts: NestedWorkloadContextByGuestId = {}; + for (const [parentId, items] of itemsByParentId) { + const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name)); + const label = chooseContextLabel(sortedItems); + contexts[parentId] = { + type: 'app-container', + label, + title: label === 'Containers' ? 'Nested containers' : `Nested ${label}`, + count: sortedItems.length, + href: NESTED_WORKLOAD_DETAILS_PATH, + items: sortedItems, + }; + } + + return contexts; +}; diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 413f163a2b..e2e1b576ed 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -3,7 +3,7 @@ import { useNavigate } from '@solidjs/router'; import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections'; import type { VM, Container, Node } from '@/types/api'; import type { Resource } from '@/types/resource'; -import type { ViewMode, WorkloadGuest } from '@/types/workloads'; +import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads'; import { useWebSocket } from '@/contexts/appRuntime'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; @@ -18,7 +18,7 @@ import { getWorkloadsLoadingState, getWorkloadsNoInventoryState, } from '@/utils/workloadEmptyStatePresentation'; -import { getCanonicalWorkloadId } from '@/utils/workloads'; +import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads'; import { nodeFromResource } from '@/utils/resourceStateAdapters'; import { buildWorkloadSummaryGroupScopeMap, @@ -40,6 +40,10 @@ import { useWorkloadsDerivedState } from './useWorkloadsDerivedState'; import { useWorkloadRouteState } from './useWorkloadRouteState'; import { buildWorkloadInventorySourceIssues } from './workloadInventorySourceIssues'; import { useWorkloadTableMetricHistory } from './useWorkloadTableMetricHistory'; +import { + buildNestedWorkloadContextByGuestId, + type NestedWorkloadContextByGuestId, +} from './nestedWorkloadContext'; const WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY = 'type=agent,docker-host,k8s-cluster,k8s-node,pbs,pmg,storage,physical_disk,ceph'; @@ -62,6 +66,8 @@ export interface WorkloadsSurfaceProps { useWorkloads?: boolean; forcedPlatform?: string; forcedViewMode?: ViewMode; + excludedWorkloadTypes?: readonly WorkloadType[]; + showNestedExcludedWorkloads?: boolean; forcedGroupingMode?: WorkloadsGroupingMode; defaultSortKey?: WorkloadsSortKey; filterAriaLabel?: string; @@ -152,8 +158,28 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { return deduped; }; - const allGuests = createMemo(() => - workloadsEnabled() ? dedupeGuests(workloads.workloads()) : [], + const excludedWorkloadTypeSet = createMemo(() => new Set(props.excludedWorkloadTypes ?? [])); + const rawGuests = createMemo(() => + workloadsEnabled() ? workloads.workloads() : [], + ); + const allGuests = createMemo(() => { + if (!workloadsEnabled()) return []; + const excludedTypes = excludedWorkloadTypeSet(); + const visibleGuests = + excludedTypes.size === 0 + ? rawGuests() + : rawGuests().filter((guest) => !excludedTypes.has(resolveWorkloadType(guest))); + return dedupeGuests(visibleGuests); + }); + const nestedWorkloadContextByGuestId = createMemo(() => + props.showNestedExcludedWorkloads + ? buildNestedWorkloadContextByGuestId({ + guests: rawGuests(), + visibleGuests: allGuests(), + excludedWorkloadTypes: props.excludedWorkloadTypes, + platformScope: props.forcedPlatform, + }) + : {}, ); const [showFilters, setShowFilters] = usePersistentSignal( @@ -479,6 +505,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { navigate, nodeByInstance, namespaceFilterConfig, + nestedWorkloadContextByGuestId, platformFilterConfig: props.suppressPlatformFilter ? () => undefined : platformFilterConfig, platformOptions, reconnect, diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 5d3fa73575..1dfebfb7bb 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -256,6 +256,17 @@ describe('platform overview layout guardrails', () => { expect(proxmoxPageSurfaceSource).toContain('(PROXMOX_TAB_SPECS.map((tab) => tab.id)); const PROXMOX_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ { value: 'all', label: 'All' }, @@ -91,9 +92,7 @@ export function ProxmoxPageSurface() { const replicationJobCount = createMemo(() => replicationJobs.error ? 0 : (replicationJobs() ?? []).length, ); - const visibleTabs = createMemo(() => - buildVisibleProxmoxTabSpecs(model(), replicationJobCount()), - ); + const visibleTabs = createMemo(() => buildVisibleProxmoxTabSpecs(model(), replicationJobCount())); const visibleTabIds = createMemo( () => new Set(visibleTabs().map((tab) => tab.id)), ); @@ -262,6 +261,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { nodes: [], useWorkloads: true, forcedPlatform: PROXMOX_PLATFORM_FILTER, + excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES, + showNestedExcludedWorkloads: true, suppressPlatformFilter: true, // The polymorphic 'info' column always holds the VMID on a pure-Proxmox // surface, so label it with the platform vocabulary instead of 'Info'. @@ -344,6 +345,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { nodes={[]} useWorkloads forcedPlatform={PROXMOX_PLATFORM_FILTER} + excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES} + showNestedExcludedWorkloads compactGroupHeaders groupNodeDrawerMode="disabled" suppressFilterToolbar diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 8a955a0251..f22c353588 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable'; import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw'; +import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw'; import { PLATFORM_TABLE_BODY_CLASS, PLATFORM_TABLE_HEADER_ROW_CLASS, @@ -146,9 +147,7 @@ const pbsServerResource = { instanceId: 'pbs-main', version: '3.2.1', connectionHealth: 'healthy', - datastores: [ - { name: 'main', total: 10_000, used: 4_000, available: 6_000, usagePercent: 40 }, - ], + datastores: [{ name: 'main', total: 10_000, used: 4_000, available: 6_000, usagePercent: 40 }], }, } as Resource; @@ -245,9 +244,7 @@ describe('ProxmoxBackupsTable', () => { expect(screen.getAllByText('Current').length).toBeGreaterThan(0); // Per-source detail is one click down inside the workload's row. - await fireEvent.click( - screen.getByRole('button', { name: /expand details for pbs-docker/i }), - ); + await fireEvent.click(screen.getByRole('button', { name: /expand details for pbs-docker/i })); expect(screen.getByText('Restore evidence')).toBeInTheDocument(); expect(screen.getAllByText('PVE file').length).toBeGreaterThan(0); expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0); @@ -289,4 +286,17 @@ describe('ProxmoxBackupsTable', () => { 'inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover', ); }); + + it('keeps backup coverage fed by Proxmox VM/LXC guests when Overview demotes app containers', () => { + expect(proxmoxPageSurfaceSource).toContain( + 'excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES', + ); + expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads: true'); + expect(proxmoxPageSurfaceSource).toContain( + 'excludedWorkloadTypes={PROXMOX_WORKLOAD_EXCLUDED_TYPES}', + ); + expect(proxmoxPageSurfaceSource).toContain('showNestedExcludedWorkloads'); + expect(proxmoxPageSurfaceSource).toContain('workloads={model().guests}'); + expect(proxmoxPageSurfaceSource).not.toContain('workloads={workloadsState.allGuests'); + }); });