Fix Docker workload host scoping

This commit is contained in:
rcourtman 2026-04-28 21:28:11 +01:00
parent 870958ef2a
commit 410a131a3b
8 changed files with 358 additions and 12 deletions

View file

@ -190,6 +190,7 @@ regression protection.
13. Extend dashboard guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts` rather than rebuilding dashboard-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
14. Extend dashboard deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Dashboard/dashboardSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`; canonical typed workload IDs such as `app-container:<host>:<provider-id>` must remain exact route/selection keys and must not be reinterpreted into synthetic node scopes
15. Extend dashboard workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts`, `frontend-modern/src/components/Dashboard/useDashboardWorkloadFilterOptions.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadFilterConfigModel.ts`, and `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Dashboard/useDashboardWorkloadUrlSync.ts` and `frontend-modern/src/components/Dashboard/dashboardWorkloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
Workloads route host scopes must resolve through `frontend-modern/src/components/Dashboard/workloadTopology.ts`: app-container scopes use the canonical Docker/runtime host id before host labels or node fallbacks, while VM and system-container scopes keep the instance-node key. Route `agent` filters and option catalogs must consume that shared scope so Infrastructure related-workload links cannot drift from Workloads filtering.
16. Extend grouped dashboard workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
17. Extend dashboard control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Dashboard/useDashboardControlsState.ts` and `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`
18. Extend dashboard filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` and `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Dashboard/DashboardFilter.tsx`

View file

@ -61,6 +61,44 @@ describe('dashboardWorkloadRouteModel', () => {
]);
});
it('builds app-container host options from Docker host ids and host labels', () => {
const options = buildDashboardWorkloadNodeOptions([
makeGuest({
id: 'docker-a',
type: 'app-container',
workloadType: 'app-container',
node: '',
instance: '',
contextLabel: 'tower.local',
dockerHostId: 'docker-host-1',
}),
makeGuest({
id: 'docker-b',
type: 'app-container',
workloadType: 'app-container',
node: '',
instance: '',
contextLabel: 'tower.local',
dockerHostId: 'docker-host-1',
}),
makeGuest({
id: 'truenas-nextcloud',
type: 'app-container',
workloadType: 'app-container',
node: '',
instance: 'nextcloud',
contextLabel: 'truenas-main',
dockerHostId: '',
platformType: 'truenas',
}),
]);
expect(options).toEqual([
{ value: 'docker-host-1', label: 'tower.local' },
{ value: 'truenas-main', label: 'truenas-main' },
]);
});
it('builds kubernetes context and namespace options from canonical pod scope', () => {
const guests = [
makeGuest({

View file

@ -248,6 +248,67 @@ describe('workloadSelectors', () => {
expect(result.map((guest) => guest.name)).toEqual(['nextcloud']);
});
it('filters app containers by Docker host id from related-workload links', () => {
const guests = [
makeGuest(1, {
name: 'grafana',
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
dockerHostId: 'docker-host-1',
contextLabel: 'tower.local',
node: '',
instance: '',
}),
makeGuest(2, {
name: 'prometheus',
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
dockerHostId: 'docker-host-1',
contextLabel: 'tower.local',
node: '',
instance: '',
}),
makeGuest(3, {
name: 'redis',
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
dockerHostId: 'docker-host-2',
contextLabel: 'edge.local',
node: '',
instance: '',
}),
];
const byCanonicalHostScope = filterWorkloads({
guests,
viewMode: 'app-container',
statusMode: 'all',
searchTerm: '',
selectedNode: 'docker-host-1',
selectedHostHint: null,
selectedPlatform: 'docker',
selectedKubernetesContext: null,
});
expect(byCanonicalHostScope.map((guest) => guest.name)).toEqual(['grafana', 'prometheus']);
const byRouteHostHint = filterWorkloads({
guests,
viewMode: 'app-container',
statusMode: 'all',
searchTerm: '',
selectedNode: null,
selectedHostHint: 'docker-host-2',
selectedPlatform: 'docker',
selectedKubernetesContext: null,
});
expect(byRouteHostHint.map((guest) => guest.name)).toEqual(['redis']);
});
});
describe('createWorkloadSortComparator', () => {

View file

@ -157,11 +157,21 @@ describe('workloadTopology', () => {
node: 'truenas-main',
instance: 'truenas-main',
});
const appContainerWithHostLabel = makeGuest(4, {
type: 'app-container',
workloadType: 'app-container',
platformType: 'docker',
dockerHostId: '',
contextLabel: 'tower.local',
node: 'node-c',
instance: 'inst-c',
});
expect(getWorkloadDockerHostId(dockerWithHostId)).toBe('docker-host-1');
expect(getWorkloadDockerHostId(dockerFallback)).toBe('');
expect(getWorkloadDockerHostId(truenasApp)).toBe('');
expect(getWorkloadContainerHostId(dockerFallback)).toBe('node-b');
expect(getWorkloadContainerHostId(truenasApp)).toBe('truenas-main');
expect(getWorkloadContainerHostId(appContainerWithHostLabel)).toBe('tower.local');
});
it('maps discovery host and resource IDs for app-container, pod, and vm', () => {

View file

@ -2,7 +2,11 @@ import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import { normalizeWorkloadViewModeParam, resolveWorkloadType } from '@/utils/workloads';
import { buildSourcePlatformOptions } from '@/utils/sourcePlatformOptions';
import type { DashboardFilterSelectOption } from './dashboardFilterModel';
import { getKubernetesContextKey, workloadNodeScopeId } from './workloadTopology';
import {
getKubernetesContextKey,
getWorkloadHostLabel,
workloadHostScopeId,
} from './workloadTopology';
export type DashboardWorkloadNodeOption = DashboardFilterSelectOption;
@ -15,27 +19,42 @@ export const buildDashboardWorkloadNodeOptions = (
guests: WorkloadGuest[],
): DashboardWorkloadNodeOption[] => {
const labelsByScope = new Map<string, string>();
const nodeNameCounts = new Map<string, number>();
const scopesByLabel = new Map<string, Set<string>>();
for (const guest of guests) {
const type = resolveWorkloadType(guest);
if (type === 'pod') continue;
const scope = workloadNodeScopeId(guest);
const scope = workloadHostScopeId(guest);
if (!scope || scope === '-') continue;
const nodeName = (guest.node || '').trim();
if (!nodeName) continue;
nodeNameCounts.set(nodeName, (nodeNameCounts.get(nodeName) || 0) + 1);
const label = getWorkloadHostLabel(guest);
if (!label) continue;
const disambiguationLabel = type === 'app-container' ? label : nodeName;
if (!disambiguationLabel) continue;
const scopes = scopesByLabel.get(disambiguationLabel) ?? new Set<string>();
scopes.add(scope);
scopesByLabel.set(disambiguationLabel, scopes);
}
for (const guest of guests) {
const type = resolveWorkloadType(guest);
if (type === 'pod') continue;
const scope = workloadNodeScopeId(guest);
const scope = workloadHostScopeId(guest);
if (!scope || scope === '-' || labelsByScope.has(scope)) continue;
if (type === 'app-container') {
const hostLabel = getWorkloadHostLabel(guest);
if (!hostLabel) continue;
const hasDuplicateHostLabel = (scopesByLabel.get(hostLabel)?.size ?? 0) > 1;
labelsByScope.set(
scope,
hasDuplicateHostLabel && scope !== hostLabel ? `${hostLabel} (${scope})` : hostLabel,
);
continue;
}
const nodeName = (guest.node || '').trim();
const instance = (guest.instance || '').trim();
if (!nodeName) continue;
const hasDuplicateNodeName = (nodeNameCounts.get(nodeName) || 0) > 1;
const hasDuplicateNodeName = (scopesByLabel.get(nodeName)?.size ?? 0) > 1;
const label = hasDuplicateNodeName && instance ? `${nodeName} (${instance})` : nodeName;
labelsByScope.set(scope, label);
}

View file

@ -9,7 +9,12 @@ import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/statu
import { getNodeDisplayName } from '@/utils/nodes';
import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation';
import { buildNodeByInstance, getKubernetesContextKey, workloadNodeScopeId } from './workloadTopology';
import {
buildNodeByInstance,
getKubernetesContextKey,
getWorkloadHostHintCandidates,
workloadHostScopeId,
} from './workloadTopology';
export interface FilterWorkloadsParams {
guests: WorkloadGuest[];
@ -55,15 +60,16 @@ export const filterWorkloads = ({
const nodeScope = selectedNode;
if (nodeScope && viewMode !== 'pod') {
guests = guests.filter((g) => workloadNodeScopeId(g) === nodeScope);
guests = guests.filter((g) => workloadHostScopeId(g) === nodeScope);
}
const hostHint = (selectedHostHint || '').trim().toLowerCase();
if (!nodeScope && hostHint && viewMode !== 'pod') {
guests = guests.filter((g) => {
if (resolveWorkloadType(g) === 'pod') return false;
const candidates = [g.node, g.instance, g.contextLabel];
return candidates.some((candidate) => (candidate || '').toLowerCase().includes(hostHint));
return getWorkloadHostHintCandidates(g).some((candidate) =>
candidate.toLowerCase().includes(hostHint),
);
});
}

View file

@ -6,6 +6,28 @@ import {
resolveWorkloadType,
} from '@/utils/workloads';
const firstTrimmed = (values: Array<string | null | undefined>): string => {
for (const value of values) {
const trimmed = (value || '').trim();
if (trimmed) return trimmed;
}
return '';
};
const dedupeTrimmed = (values: Array<string | null | undefined>): string[] => {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const trimmed = (value || '').trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
return result;
};
export const workloadNodeScopeId = (guest: WorkloadGuest): string =>
`${(guest.instance || '').trim()}-${(guest.node || '').trim()}`;
@ -29,7 +51,31 @@ export const getWorkloadDockerHostId = (guest: WorkloadGuest): string => {
export const getWorkloadContainerHostId = (guest: WorkloadGuest): string => {
const type = resolveWorkloadType(guest);
if (type !== 'app-container') return '';
return (guest.dockerHostId || guest.node || guest.instance || '').trim();
return firstTrimmed([guest.dockerHostId, guest.contextLabel, guest.node, guest.instance]);
};
export const workloadHostScopeId = (guest: WorkloadGuest): string => {
const type = resolveWorkloadType(guest);
if (type === 'pod') return '';
if (type === 'app-container') return getWorkloadContainerHostId(guest);
return workloadNodeScopeId(guest);
};
export const getWorkloadHostLabel = (guest: WorkloadGuest): string => {
const type = resolveWorkloadType(guest);
if (type === 'app-container') {
return firstTrimmed([guest.contextLabel, guest.node, guest.instance, guest.dockerHostId]);
}
return (guest.node || '').trim();
};
export const getWorkloadHostHintCandidates = (guest: WorkloadGuest): string[] => {
const type = resolveWorkloadType(guest);
if (type === 'pod') return [];
if (type === 'app-container') {
return dedupeTrimmed([guest.dockerHostId, guest.contextLabel, guest.node, guest.instance]);
}
return dedupeTrimmed([guest.node, guest.instance, guest.contextLabel]);
};
export const getDiscoveryHostIdForWorkload = (guest: WorkloadGuest): string => {

View file

@ -0,0 +1,165 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test as base, expect } from '@playwright/test';
import { createAuthenticatedStorageState } from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const SCREENSHOT_PATH = '/tmp/docker-workloads-host-filter.png';
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`docker-workloads-host-filter-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: 'worker' },
],
});
test.describe('Docker workloads host filter', () => {
test.setTimeout(180_000);
test('keeps Docker related-workload links scoped to the selected runtime host', async ({
page,
}) => {
await page.route('**/api/resources**', async (route) => {
const requestUrl = new URL(route.request().url());
if (
requestUrl.pathname !== '/api/resources' ||
requestUrl.searchParams.get('type') !== 'vm,system-container,app-container,pod'
) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{
id: 'app-container:docker-host-1:grafana',
type: 'app-container',
name: 'grafana',
status: 'running',
lastSeen: '2026-04-28T12:00:00Z',
sources: ['docker'],
parentName: 'tower.local',
metrics: {
cpu: { value: 8, percent: 8 },
memory: { total: 4 * 1024 * 1024 * 1024, used: 1 * 1024 * 1024 * 1024 },
disk: { total: 40 * 1024 * 1024 * 1024, used: 10 * 1024 * 1024 * 1024 },
netIn: { value: 1024 },
netOut: { value: 512 },
diskRead: { value: 128 },
diskWrite: { value: 64 },
},
docker: {
containerId: 'docker-grafana',
hostname: 'tower.local',
imageName: 'grafana:11',
runtime: 'docker',
hostSourceId: 'docker-host-1',
},
},
{
id: 'app-container:docker-host-1:prometheus',
type: 'app-container',
name: 'prometheus',
status: 'running',
lastSeen: '2026-04-28T12:00:00Z',
sources: ['docker'],
parentName: 'tower.local',
metrics: {
cpu: { value: 10, percent: 10 },
memory: { total: 8 * 1024 * 1024 * 1024, used: 2 * 1024 * 1024 * 1024 },
disk: { total: 80 * 1024 * 1024 * 1024, used: 20 * 1024 * 1024 * 1024 },
netIn: { value: 2048 },
netOut: { value: 1024 },
diskRead: { value: 256 },
diskWrite: { value: 128 },
},
docker: {
containerId: 'docker-prometheus',
hostname: 'tower.local',
imageName: 'prom/prometheus:latest',
runtime: 'podman',
hostSourceId: 'docker-host-1',
},
},
{
id: 'app-container:docker-host-2:redis',
type: 'app-container',
name: 'redis',
status: 'running',
lastSeen: '2026-04-28T12:00:00Z',
sources: ['docker'],
parentName: 'edge.local',
metrics: {
cpu: { value: 4, percent: 4 },
memory: { total: 2 * 1024 * 1024 * 1024, used: 512 * 1024 * 1024 },
disk: { total: 20 * 1024 * 1024 * 1024, used: 5 * 1024 * 1024 * 1024 },
netIn: { value: 512 },
netOut: { value: 256 },
diskRead: { value: 64 },
diskWrite: { value: 32 },
},
docker: {
containerId: 'docker-redis',
hostname: 'edge.local',
imageName: 'redis:7',
runtime: 'docker',
hostSourceId: 'docker-host-2',
},
},
],
meta: {
page: 1,
limit: 200,
total: 3,
totalPages: 1,
},
}),
});
});
await page.goto('/workloads?type=app-container&platform=docker&agent=docker-host-1', {
waitUntil: 'domcontentloaded',
});
await page.waitForURL(/\/workloads\?type=app-container&platform=docker&agent=docker-host-1/);
await expect(page.locator('#dashboard-type-filter')).toHaveValue('app-container');
await expect(page.locator('#workloads-platform-filter')).toHaveValue('docker');
await expect(page.locator('#workloads-node-filter')).toHaveValue('docker-host-1');
const workloadTable = page.locator('table').first();
await expect(workloadTable).toContainText('grafana');
await expect(workloadTable).toContainText('prometheus');
await expect(workloadTable).not.toContainText('redis');
await expect(page.getByText('No guests found')).toHaveCount(0);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
});
});