Show nested Docker context in LXC drawers

This commit is contained in:
rcourtman 2026-06-12 15:30:37 +01:00
parent 87e19f5101
commit 23e100e4c0
19 changed files with 618 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -571,6 +571,38 @@ describe('GuestDrawer', () => {
});
});
describe('Nested workload context card', () => {
it('shows nested Docker container count, sample rows, and canonical Docker link', () => {
render(() => (
<GuestDrawer
guest={makeGuest({ name: 'media-lxc', type: 'lxc', workloadType: 'system-container' })}
nestedWorkloadContext={{
type: 'app-container',
label: 'Docker',
title: 'Nested Docker',
count: 2,
href: '/docker',
items: [
{ id: 'container-1', name: 'frigate', runtimeLabel: 'Docker', status: 'running' },
{ id: 'container-2', name: 'mosquitto', runtimeLabel: 'Docker', status: 'exited' },
],
}}
onClose={vi.fn()}
/>
));
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', () => {

View file

@ -146,6 +146,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
hasOsInfo={hasOsInfo()}
ipAddresses={ipAddresses()}
networkInterfaces={networkInterfaces()}
nestedWorkloadContext={props.nestedWorkloadContext}
normalizedTags={normalizedTags()}
onCustomUrlChange={props.onCustomUrlChange}
customUrl={props.customUrl}

View file

@ -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<GuestDrawerProps['guest']['networkInterfaces']>;
nestedWorkloadContext?: NestedWorkloadContext;
normalizedTags: string[];
onCustomUrlChange?: GuestDrawerProps['onCustomUrlChange'];
customUrl?: GuestDrawerProps['customUrl'];
@ -215,6 +218,54 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
</div>
</div>
<Show when={props.nestedWorkloadContext}>
{(context) => {
const visibleItems = () => context().items.slice(0, 4);
const hiddenCount = () => Math.max(0, context().count - visibleItems().length);
return (
<div
class="rounded border border-border bg-surface p-3 shadow-sm"
data-testid="nested-workload-context-card"
>
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
{context().title}
</h3>
<a
href={context().href}
class="inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold text-blue-600 underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-blue-300"
aria-label={`Open ${context().label} page for ${props.guest.name}`}
>
<BoxIcon class="h-3 w-3" aria-hidden="true" />
<span>Open Docker</span>
</a>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Containers</span>
<span class="font-medium text-base-content">{context().count}</span>
</div>
<For each={visibleItems()}>
{(item) => (
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<span class="truncate font-medium text-base-content" title={item.name}>
{item.name}
</span>
<span class="rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted">
{item.status}
</span>
</div>
)}
</For>
<Show when={hiddenCount() > 0}>
<div class="text-[10px] font-medium text-muted">+{hiddenCount()} more</div>
</Show>
</div>
</div>
);
}}
</Show>
{/* 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) => (
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="shrink-0 text-muted">{row.label}</span>
<span class="truncate text-right font-medium text-base-content" title={row.value}>
<span
class="truncate text-right font-medium text-base-content"
title={row.value}
>
{row.value}
</span>
</div>

View file

@ -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 (
<ComponentErrorBoundary name="GuestRow">
@ -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()}
/>

View file

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

View file

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

View file

@ -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(() => (
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedPlatform="proxmox-pve"
forcedViewMode="all"
excludedWorkloadTypes={['app-container']}
/>
));
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(() => (
<WorkloadsSurface

View file

@ -228,7 +228,16 @@ vi.mock('../WorkloadsFilter', () => ({
}));
vi.mock('../GuestDrawer', () => ({
GuestDrawer: () => <div data-testid="guest-drawer">drawer</div>,
GuestDrawer: (props: { nestedWorkloadContext?: { label: string; count: number } }) => (
<div data-testid="guest-drawer">
drawer
<span data-testid="drawer-nested-context">
{props.nestedWorkloadContext
? `${props.nestedWorkloadContext.label} ${props.nestedWorkloadContext.count}`
: ''}
</span>
</div>
),
}));
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(() => (
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
forcedPlatform="proxmox-pve"
forcedViewMode="all"
excludedWorkloadTypes={['app-container']}
showNestedExcludedWorkloads
/>
));
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');

View file

@ -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>): 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({});
});
});

View file

@ -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) });

View file

@ -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<string, NestedWorkloadContext>;
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<string>, value?: string | number | null): void => {
const normalized = normalizeCandidate(value);
if (normalized) target.add(normalized);
};
const addHostnameCandidates = (target: Set<string>, 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<string> => {
const candidates = new Set<string>();
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<string>, 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<string> => {
const candidates = new Set<string>();
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<string, string | null>();
const parentById = new Map<string, WorkloadGuest>();
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<string, NestedWorkloadContextItem[]>();
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;
};

View file

@ -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<WorkloadGuest[]>(() =>
workloadsEnabled() ? dedupeGuests(workloads.workloads()) : [],
const excludedWorkloadTypeSet = createMemo(() => new Set(props.excludedWorkloadTypes ?? []));
const rawGuests = createMemo<WorkloadGuest[]>(() =>
workloadsEnabled() ? workloads.workloads() : [],
);
const allGuests = createMemo<WorkloadGuest[]>(() => {
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<NestedWorkloadContextByGuestId>(() =>
props.showNestedExcludedWorkloads
? buildNestedWorkloadContextByGuestId({
guests: rawGuests(),
visibleGuests: allGuests(),
excludedWorkloadTypes: props.excludedWorkloadTypes,
platformScope: props.forcedPlatform,
})
: {},
);
const [showFilters, setShowFilters] = usePersistentSignal<boolean>(
@ -479,6 +505,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
navigate,
nodeByInstance,
namespaceFilterConfig,
nestedWorkloadContextByGuestId,
platformFilterConfig: props.suppressPlatformFilter ? () => undefined : platformFilterConfig,
platformOptions,
reconnect,

View file

@ -256,6 +256,17 @@ describe('platform overview layout guardrails', () => {
expect(proxmoxPageSurfaceSource).toContain('<ProxmoxNodesTable');
expect(proxmoxPageSurfaceSource).toContain('<WorkloadsSurface');
expect(proxmoxPageSurfaceSource).toContain(
"const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const",
);
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(dockerPageSurfaceSource).toContain('<DockerHostsTable');
expect(dockerPageSurfaceSource).toContain('<DockerContainersTable');
expect(dockerPageSurfaceSource).toContain('<DockerImagesTable');

View file

@ -63,6 +63,7 @@ const PROXMOX_RESOURCE_QUERY =
const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';
const PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE = 'proxmox';
const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const;
const VALID_TABS = new Set<ProxmoxPageTabId>(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<ProxmoxPageTabId>(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

View file

@ -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');
});
});