Scope Docker drawer handoff by host

This commit is contained in:
rcourtman 2026-06-12 15:54:29 +01:00
parent 6ca91d47e6
commit 21c79c898c
16 changed files with 266 additions and 13 deletions

View file

@ -839,6 +839,11 @@ not a replacement status card, CTA band, or page-local nested card.
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.
Drawer-to-runtime navigation is still part of the shared platform-table
affordance contract: when the LXC drawer exposes an `Open Docker` action for
nested containers, that action must use the Docker host facet route state so
the target Docker Overview opens scoped to the same runtime instead of a
broad, visually unrelated container list.
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

@ -167,6 +167,11 @@ regression protection.
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 bounded derivation owns Docker host-facet handoff data for nested
runtime links. `useWorkloads.ts` must carry the Docker runtime hostname onto
`WorkloadGuest` so `nestedWorkloadContext.ts` can build a route-scoped Docker
link without scanning unified resources again inside drawers or Docker table
renderers.
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

@ -146,6 +146,10 @@ 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.
Docker-in-LXC drawer links may add Docker runtime host route state through the
shared `resourceLinks.ts` module, but that query state is Docker-lens scope
only. Storage and recovery route builders/parsers must not reuse that Docker
host facet or treat it as backup-source, datastore, node, 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

@ -207,6 +207,12 @@ workload, service, storage, configuration, policy, and event object rows belong
in their workflow tabs. If a future Overview repeats a detailed table, the
owning workflow must be retired or the Overview content must be reduced to
aggregate signal.
Cross-platform handoffs into the Docker / Podman Overview must preserve runtime
scope when the source surface knows it. A Proxmox LXC drawer that links to the
Docker runtime lens must carry the unambiguous Docker host facet as route state,
and Docker Overview must apply that host scope to both the runtime host summary
and the primary container table so the handoff lands on the same runtime the
operator just inspected.
Platform native tables are unified-resource consumers even when their visual
table frame is frontend-primitives-owned. Docker / Podman, Kubernetes, Proxmox,
Standalone, TrueNAS, and vSphere platform tables own source-specific row fields,
@ -2290,7 +2296,12 @@ 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.
model; ambiguous runtime-host matches must be omitted rather than guessed. When
that drawer links into the Docker / Podman runtime lens, it must carry the
unambiguous Docker host facet in the route (`host=<runtime hostname>`) and the
Docker overview must apply that scope to the host and container tables; if the
runtime host label is missing or ambiguous, the handoff falls back to the
unfiltered Docker overview.
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

@ -581,7 +581,7 @@ describe('GuestDrawer', () => {
label: 'Docker',
title: 'Nested Docker',
count: 2,
href: '/docker',
href: '/docker/overview?host=media-lxc.mist-stork.ts.net',
items: [
{ id: 'container-1', name: 'frigate', runtimeLabel: 'Docker', status: 'running' },
{ id: 'container-2', name: 'mosquitto', runtimeLabel: 'Docker', status: 'exited' },
@ -599,7 +599,7 @@ describe('GuestDrawer', () => {
expect(within(card).getByText('mosquitto')).toBeInTheDocument();
expect(
within(card).getByRole('link', { name: 'Open Docker page for media-lxc' }),
).toHaveAttribute('href', '/docker');
).toHaveAttribute('href', '/docker/overview?host=media-lxc.mist-stork.ts.net');
});
});

View file

@ -49,6 +49,7 @@ describe('nested workload context', () => {
platformScopes: ['proxmox-pve', 'docker'],
containerRuntime: 'docker',
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
dockerHostName: 'media-lxc.mist-stork.ts.net',
image: 'ghcr.io/blakeblackshear/frigate:stable',
});
@ -62,11 +63,55 @@ describe('nested workload context', () => {
expect(result['pve-a:node-a:141']).toMatchObject({
label: 'Docker',
count: 1,
href: '/docker',
href: '/docker/overview?host=media-lxc.mist-stork.ts.net',
items: [{ name: 'frigate', status: 'running' }],
});
});
it('falls back to the Docker overview when nested containers do not share one host label', () => {
const lxc = makeGuest({
id: 'lxc-141',
vmid: 141,
name: 'media-lxc',
node: 'node-a',
instance: 'pve-a',
platformScopes: ['proxmox-pve'],
});
const firstContainer = makeGuest({
id: 'app-container:first',
vmid: 0,
name: 'first',
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
platformScopes: ['proxmox-pve', 'docker'],
containerRuntime: 'docker',
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
dockerHostName: 'first-host.local',
});
const secondContainer = makeGuest({
id: 'app-container:second',
vmid: 0,
name: 'second',
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
platformScopes: ['proxmox-pve', 'docker'],
containerRuntime: 'docker',
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
dockerHostName: 'second-host.local',
});
const result = buildNestedWorkloadContextByGuestId({
guests: [lxc, firstContainer, secondContainer],
visibleGuests: [lxc],
excludedWorkloadTypes: ['app-container'],
platformScope: 'proxmox-pve',
});
expect(result['pve-a:node-a:141']?.href).toBe('/docker/overview');
});
it('does not guess when Docker host identity only matches ambiguous VMIDs', () => {
const firstLxc = makeGuest({
id: 'lxc-a-141',

View file

@ -1,4 +1,5 @@
import type { WorkloadGuest, WorkloadType } from '@/types/workloads';
import { buildDockerPath, buildDockerRouteSearch } from '@/routing/resourceLinks';
import {
getCanonicalWorkloadId,
resolveWorkloadType,
@ -6,7 +7,6 @@ import {
} from '@/utils/workloads';
const LXC_DOCKER_HOST_PREFIX = 'proxmox-lxc-docker:';
const NESTED_WORKLOAD_DETAILS_PATH = '/docker';
export interface NestedWorkloadContextItem {
id: string;
@ -33,6 +33,10 @@ interface BuildNestedWorkloadContextParams {
platformScope?: string | null;
}
type NestedWorkloadContextBuildItem = NestedWorkloadContextItem & {
hostLabel: string;
};
const cleanText = (value?: string | number | null): string => String(value ?? '').trim();
const normalizeCandidate = (value?: string | number | null): string =>
@ -103,11 +107,12 @@ const formatRuntimeLabel = (runtime?: string | null): string => {
const formatStatusLabel = (status?: string | null): string => cleanText(status) || 'unknown';
const createNestedItem = (guest: WorkloadGuest): NestedWorkloadContextItem => ({
const createNestedItem = (guest: WorkloadGuest): NestedWorkloadContextBuildItem => ({
id: getCanonicalWorkloadId(guest),
name: cleanText(guest.name) || cleanText(guest.containerId) || getCanonicalWorkloadId(guest),
status: formatStatusLabel(guest.status),
runtimeLabel: formatRuntimeLabel(guest.containerRuntime),
hostLabel: cleanText(guest.dockerHostName) || cleanText(guest.contextLabel),
});
const chooseContextLabel = (items: readonly NestedWorkloadContextItem[]): string => {
@ -116,6 +121,23 @@ const chooseContextLabel = (items: readonly NestedWorkloadContextItem[]): string
return 'Containers';
};
const chooseDockerHostFilter = (items: readonly NestedWorkloadContextBuildItem[]): string => {
const labels = Array.from(new Set(items.map((item) => item.hostLabel).filter(Boolean)));
return labels.length === 1 ? labels[0] : '';
};
const buildNestedWorkloadHref = (host: string): string =>
`${buildDockerPath('overview')}${buildDockerRouteSearch({ host })}`;
const toNestedWorkloadContextItem = (
item: NestedWorkloadContextBuildItem,
): NestedWorkloadContextItem => ({
id: item.id,
name: item.name,
status: item.status,
runtimeLabel: item.runtimeLabel,
});
export const buildNestedWorkloadContextByGuestId = ({
guests,
visibleGuests,
@ -142,7 +164,7 @@ export const buildNestedWorkloadContextByGuestId = ({
}
}
const itemsByParentId = new Map<string, NestedWorkloadContextItem[]>();
const itemsByParentId = new Map<string, NestedWorkloadContextBuildItem[]>();
for (const guest of guests) {
if (resolveWorkloadType(guest) !== 'app-container') continue;
if (!workloadMatchesPlatformScope(guest, platformScope)) continue;
@ -166,13 +188,14 @@ export const buildNestedWorkloadContextByGuestId = ({
for (const [parentId, items] of itemsByParentId) {
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name));
const label = chooseContextLabel(sortedItems);
const host = chooseDockerHostFilter(sortedItems);
contexts[parentId] = {
type: 'app-container',
label,
title: label === 'Containers' ? 'Nested containers' : `Nested ${label}`,
count: sortedItems.length,
href: NESTED_WORKLOAD_DETAILS_PATH,
items: sortedItems,
href: buildNestedWorkloadHref(host),
items: sortedItems.map(toNestedWorkloadContextItem),
};
}

View file

@ -9,6 +9,7 @@ import { useBreakpoint } from '@/hooks/useBreakpoint';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformTableEmptyState,
@ -194,8 +195,12 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
// only appears once more than one host is present (a single-host fleet has
// nothing to scope by).
const [searchParams, setSearchParams] = useSearchParams();
const hostFilter = () => (typeof searchParams.host === 'string' ? searchParams.host : '');
const setHostFilter = (value: string) => setSearchParams({ host: value || null });
const hostFilter = () => {
const host = searchParams[DOCKER_QUERY_PARAMS.host];
return typeof host === 'string' ? host : '';
};
const setHostFilter = (value: string) =>
setSearchParams({ [DOCKER_QUERY_PARAMS.host]: value || null });
const hostOptions = createMemo(() => {
const seen = new Set<string>();
for (const resource of props.resources) {

View file

@ -1,4 +1,4 @@
import { useLocation } from '@solidjs/router';
import { useLocation, useSearchParams } from '@solidjs/router';
import { Show, createMemo, createSignal } from 'solid-js';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
@ -41,6 +41,9 @@ import {
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import { updateStore } from '@/stores/updates';
import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
import { asTrimmedString } from '@/utils/stringUtils';
import type { Resource } from '@/types/resource';
const DOCKER_RESOURCE_QUERY =
'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task,docker-swarm-node,docker-secret,docker-config';
@ -50,6 +53,7 @@ const dockerIcon = () => <DockerIcon class="h-6 w-6 text-slate-400" />;
export function DockerPageSurface() {
const location = useLocation();
const [searchParams] = useSearchParams();
const { resources, loading, error, refetch } = useUnifiedResources({
query: DOCKER_RESOURCE_QUERY,
cacheKey: 'docker-workspace',
@ -64,6 +68,10 @@ export function DockerPageSurface() {
const activeTab = createMemo<DockerPageTabId>(() =>
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
);
const hostFilter = createMemo(() => {
const rawHost = searchParams[DOCKER_QUERY_PARAMS.host];
return typeof rawHost === 'string' ? rawHost.trim() : '';
});
const agentUpdateTargetVersion = createMemo(
() => updateStore.versionInfo()?.agentUpdateTargetVersion,
);
@ -125,6 +133,7 @@ export function DockerPageSurface() {
<DockerOverview
hosts={model().hosts}
hostSourceCount={model().hosts.length}
hostFilter={hostFilter()}
containers={model().containers}
incidents={model().incidents}
/>
@ -295,13 +304,29 @@ function DockerSwarm(props: { model: DockerPageModel }) {
function DockerOverview(props: {
hosts: ReturnType<typeof buildDockerPageModel>['hosts'];
hostSourceCount: number;
hostFilter: string;
containers: ReturnType<typeof buildDockerPageModel>['containers'];
incidents: ReturnType<typeof buildDockerPageModel>['incidents'];
}) {
const hostMatchesFilter = (resource: Resource, hostFilter: string): boolean => {
const normalized = hostFilter.trim();
if (!normalized) return true;
return [
resource.docker?.hostname,
resource.name,
resource.displayName,
resource.id,
resource.docker?.hostSourceId,
].some((candidate) => asTrimmedString(candidate) === normalized);
};
const scopedHosts = createMemo(() =>
props.hosts.filter((host) => hostMatchesFilter(host, props.hostFilter)),
);
return (
<div class="space-y-4">
<DockerHostsTable
resources={props.hosts}
resources={scopedHosts()}
sourceCount={props.hostSourceCount}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman hosts"

View file

@ -95,6 +95,7 @@ const setViewportWidth = (width: number) => {
};
afterEach(() => {
window.history.pushState({}, '', '/');
setViewportWidth(1024);
cleanup();
vi.clearAllMocks();
@ -237,6 +238,50 @@ describe('Docker native tables', () => {
expect(screen.queryByText('docker 27.5.1')).not.toBeInTheDocument();
});
it('applies the URL host scope to container rows', () => {
window.history.pushState({}, '', '/?host=edge-02');
renderInRouter(() => (
<DockerContainersTable
resources={[
makeResource({
id: 'container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
docker: {
hostname: 'edge-01',
runtime: 'docker',
image: 'nginx:latest',
containerState: 'running',
},
}),
makeResource({
id: 'container-2',
type: 'app-container',
name: 'edge-cache',
status: 'running',
docker: {
hostname: 'edge-02',
runtime: 'docker',
image: 'redis:7.4',
containerState: 'running',
},
}),
]}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
expect(screen.getByText('edge-cache')).toBeInTheDocument();
expect(screen.getByText('edge-02')).toBeInTheDocument();
expect(screen.queryByText('edge-web')).not.toBeInTheDocument();
expect(screen.queryByText('edge-01')).not.toBeInTheDocument();
});
it('hides the Restarts column when the current containers have no restart signal', () => {
setViewportWidth(1500);

View file

@ -6,6 +6,7 @@ import { DockerPageSurface } from '../DockerPageSurface';
const mocks = vi.hoisted(() => ({
pathname: '/docker/overview',
searchParams: {} as Record<string, string>,
useUnifiedResources: vi.fn(),
DockerHostsTable: vi.fn(
(props: { resources: Resource[]; showToolbar?: boolean; emptyTitle: string }) => (
@ -82,6 +83,7 @@ vi.mock('@solidjs/router', () => ({
return mocks.pathname;
},
}),
useSearchParams: () => [mocks.searchParams, vi.fn()],
}));
vi.mock('../DockerContainersTable', () => ({
@ -213,6 +215,7 @@ const makeDockerNetwork = (overrides: Partial<Resource> = {}): Resource => ({
beforeEach(() => {
mocks.pathname = '/docker/overview';
mocks.searchParams = {};
mocks.useUnifiedResources.mockReturnValue({
error: () => null,
loading: () => false,
@ -276,6 +279,55 @@ describe('DockerPageSurface', () => {
);
});
it('applies the URL host scope to the overview host table', () => {
mocks.searchParams = { host: 'frigate.mist-stork.ts.net' };
mocks.useUnifiedResources.mockReturnValue({
error: () => null,
loading: () => false,
refetch: vi.fn(),
resources: () => [
makeDockerHost({
id: 'agent:frigate',
name: 'frigate',
displayName: 'frigate',
docker: {
runtime: 'docker',
runtimeVersion: '29.5.1',
hostname: 'frigate.mist-stork.ts.net',
containerCount: 2,
} as NonNullable<Resource['docker']>,
}),
makeDockerHost({
id: 'agent:tower',
name: 'tower',
displayName: 'tower',
docker: {
runtime: 'docker',
runtimeVersion: '29.5.2',
hostname: 'tower',
containerCount: 1,
} as NonNullable<Resource['docker']>,
}),
makeDockerContainer({
id: 'app-container:frigate',
docker: {
runtime: 'docker',
hostname: 'frigate.mist-stork.ts.net',
image: 'ghcr.io/blakeblackshear/frigate:stable',
} as NonNullable<Resource['docker']>,
}),
],
});
render(() => <DockerPageSurface />);
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-resource-count', '1');
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-resource-count',
'1',
);
});
it('routes stale agent notices to the agent install commands', () => {
mocks.useUnifiedResources.mockReturnValue({
error: () => null,

View file

@ -247,6 +247,7 @@ describe('useWorkloads', () => {
docker: {
containerId: 'frigate',
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141',
hostname: 'homepage-docker.lab',
runtime: 'docker',
image: 'ghcr.io/blakeblackshear/frigate:stable',
},
@ -310,6 +311,7 @@ describe('useWorkloads', () => {
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('frigate')?.dockerHostName).toBe('homepage-docker.lab');
expect(byName.get('crm-app-01')?.platformType).toBe('vmware-vsphere');
expect(byName.get('crm-app-01')?.node).toBe('esxi-01');
expect(byName.get('crm-app-01')?.instance).toBe('Compute-A');

View file

@ -543,6 +543,10 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
: undefined,
updateStatus: resource.docker?.updateStatus as WorkloadGuest['updateStatus'] | undefined,
dockerHostId: dockerManagedAppContainer ? resource.docker?.hostSourceId : undefined,
dockerHostName:
workloadType === 'app-container'
? (resource.docker?.hostname || '').trim() || undefined
: undefined,
kubernetesAgentId: workloadType === 'pod' ? resource.kubernetes?.agentId : undefined,
platformType,
platformScopes,

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
DOCKER_PATH,
DOCKER_QUERY_PARAMS,
KUBERNETES_PATH,
PMG_THRESHOLDS_PATH,
PATROL_PATH,
@ -12,6 +13,7 @@ import {
TRUENAS_PATH,
VMWARE_PATH,
buildDockerPath,
buildDockerRouteSearch,
buildKubernetesPath,
buildRecoveryRouteSearch,
buildProxmoxPath,
@ -50,6 +52,14 @@ describe('resource link routing contract', () => {
expect(buildDockerPath()).toBe('/docker/overview');
expect(buildDockerPath('containers')).toBe('/docker/containers');
expect(buildDockerPath('')).toBe('/docker');
expect(DOCKER_QUERY_PARAMS.host).toBe('host');
expect(buildDockerRouteSearch({ host: 'frigate.mist-stork.ts.net' })).toBe(
'?host=frigate.mist-stork.ts.net',
);
expect(buildDockerRouteSearch({ host: ' host with spaces ' })).toBe(
'?host=host+with+spaces',
);
expect(buildDockerRouteSearch({ host: '' })).toBe('');
expect(KUBERNETES_PATH).toBe('/kubernetes');
expect(buildKubernetesPath()).toBe('/kubernetes/overview');

View file

@ -31,6 +31,10 @@ export const VMWARE_DEFAULT_TAB = 'overview';
export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway';
export const PATROL_PATH = '/patrol';
export const DOCKER_QUERY_PARAMS = {
host: 'host',
} as const;
export const STORAGE_QUERY_PARAMS = {
tab: 'tab',
group: 'group',
@ -97,6 +101,10 @@ type WorkloadsLinkOptions = {
summaryGroup?: string | null;
};
type DockerLinkOptions = {
host?: string | null;
};
type StorageLinkOptions = {
tab?: string | null;
group?: string | null;
@ -191,6 +199,13 @@ export const buildDockerPath = (tab: string = DOCKER_DEFAULT_TAB): string => {
return normalized ? `${DOCKER_PATH}/${normalized}` : DOCKER_PATH;
};
export const buildDockerRouteSearch = (options: DockerLinkOptions = {}): string => {
const params = new URLSearchParams();
const host = normalizeQueryValue(options.host);
if (host) params.set(DOCKER_QUERY_PARAMS.host, host);
return serializedRouteSearch(params);
};
export const buildKubernetesPath = (tab: string = KUBERNETES_DEFAULT_TAB): string => {
const normalized = tab.trim().replace(/^\/+|\/+$/g, '');
return normalized ? `${KUBERNETES_PATH}/${normalized}` : KUBERNETES_PATH;

View file

@ -27,6 +27,8 @@ export type WorkloadGuest = (VM | Container) & {
// Agent-managed Docker runtime host ID for Docker-specific update actions.
// API-backed platforms such as TrueNAS must not populate this field.
dockerHostId?: string;
// Docker/Podman host label used by the Docker runtime page's host facet.
dockerHostName?: string;
// Kubernetes agent ID (when available) — preferred for actionable operations.
kubernetesAgentId?: string;
// Canonical discovery ownership. API-backed platforms such as TrueNAS should