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 5aa2d81a7..1d3efa202 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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::` 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` diff --git a/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts b/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts index 637d4d126..fb76d7dee 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts +++ b/frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts @@ -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({ diff --git a/frontend-modern/src/components/Dashboard/__tests__/workloadSelectors.test.ts b/frontend-modern/src/components/Dashboard/__tests__/workloadSelectors.test.ts index bf96bc03b..d4d145c63 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/workloadSelectors.test.ts +++ b/frontend-modern/src/components/Dashboard/__tests__/workloadSelectors.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/Dashboard/__tests__/workloadTopology.test.ts b/frontend-modern/src/components/Dashboard/__tests__/workloadTopology.test.ts index 0080176ce..6dd7b7cb2 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/workloadTopology.test.ts +++ b/frontend-modern/src/components/Dashboard/__tests__/workloadTopology.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts b/frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts index 906a84aef..b1a6b9647 100644 --- a/frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts +++ b/frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts @@ -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(); - const nodeNameCounts = new Map(); + const scopesByLabel = new Map>(); 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(); + 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); } diff --git a/frontend-modern/src/components/Dashboard/workloadSelectors.ts b/frontend-modern/src/components/Dashboard/workloadSelectors.ts index 975606ea5..664be1e20 100644 --- a/frontend-modern/src/components/Dashboard/workloadSelectors.ts +++ b/frontend-modern/src/components/Dashboard/workloadSelectors.ts @@ -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), + ); }); } diff --git a/frontend-modern/src/components/Dashboard/workloadTopology.ts b/frontend-modern/src/components/Dashboard/workloadTopology.ts index b160e3633..85915e48a 100644 --- a/frontend-modern/src/components/Dashboard/workloadTopology.ts +++ b/frontend-modern/src/components/Dashboard/workloadTopology.ts @@ -6,6 +6,28 @@ import { resolveWorkloadType, } from '@/utils/workloads'; +const firstTrimmed = (values: Array): string => { + for (const value of values) { + const trimmed = (value || '').trim(); + if (trimmed) return trimmed; + } + return ''; +}; + +const dedupeTrimmed = (values: Array): string[] => { + const seen = new Set(); + 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 => { diff --git a/tests/integration/tests/72-docker-workloads-host-filter.spec.ts b/tests/integration/tests/72-docker-workloads-host-filter.spec.ts new file mode 100644 index 000000000..1bc80d8c7 --- /dev/null +++ b/tests/integration/tests/72-docker-workloads-host-filter.spec.ts @@ -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 }); + }); +});