diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 1e501f33e..fece8b4cb 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -10,6 +10,7 @@ import { DockerHostSummaryTable, type DockerHostSummary } from './DockerHostSumm import { DockerUnifiedTable } from './DockerUnifiedTable'; import { DockerClusterServicesTable } from './DockerClusterServicesTable'; import { hasSwarmClusters } from './swarmClusterHelpers'; +import { containerMatchesDockerSearch } from './dockerSearch'; import { useWebSocket } from '@/App'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; @@ -258,9 +259,13 @@ export const DockerHosts: Component = (props) => { const updateableContainers = createMemo(() => { const containers: { hostId: string; containerId: string; containerName: string }[] = []; + const activeHostId = selectedHostId(); + const searchTerm = debouncedSearch(); sortedHosts().forEach((host) => { if (!hostMatchesStatus(host)) return; + if (activeHostId && host.id !== activeHostId) return; host.containers?.forEach((c) => { + if (!containerMatchesDockerSearch(searchTerm, host, c)) return; if (c.updateStatus?.updateAvailable) { containers.push({ hostId: host.id, diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 213b1183e..2ab724427 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -41,6 +41,11 @@ import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditP import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; import { useAlertsActivation } from '@/stores/alertsActivation'; +import { + containerMatchesDockerSearch, + getDockerHostDisplayName, + serviceMatchesDockerSearch, +} from './dockerSearch'; type DockerMetadataRecord = Record; @@ -76,8 +81,6 @@ type StatsFilter = | { type: 'service-health'; value: string } | null; -type SearchToken = { key?: string; value: string }; - type DockerRow = | { kind: 'container'; @@ -189,23 +192,7 @@ const ensureMs = (value?: number | string | null): number | null => { return Number.isNaN(parsed) ? null : parsed; }; -const parseSearchTerm = (term?: string): SearchToken[] => { - if (!term) return []; - return term - .trim() - .split(/\s+/) - .filter(Boolean) - .map((token) => { - const [rawKey, ...rest] = token.split(':'); - if (rest.length === 0) { - return { value: token.toLowerCase() }; - } - return { key: rawKey.toLowerCase(), value: rest.join(':').toLowerCase() }; - }); -}; - -const getHostDisplayName = (host: DockerHost): string => - host.customDisplayName || host.displayName || host.hostname || host.id || ''; +const getHostDisplayName = (host: DockerHost): string => getDockerHostDisplayName(host); const compareStrings = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' }); @@ -600,144 +587,6 @@ const serviceMatchesHealthFilter = (filter: StatsFilter, service: DockerService) return true; }; -const containerMatchesToken = ( - token: SearchToken, - host: DockerHost, - container: DockerContainer, -) => { - const state = toLower(container.state); - const health = toLower(container.health); - const hostName = toLower(host.customDisplayName ?? host.displayName ?? host.hostname ?? host.id); - - if (token.key === 'name') { - return ( - toLower(container.name).includes(token.value) || - toLower(container.id).includes(token.value) - ); - } - - if (token.key === 'image') { - return toLower(container.image).includes(token.value); - } - - if (token.key === 'host') { - return hostName.includes(token.value); - } - - if (token.key === 'pod') { - const pod = container.podman?.podName?.toLowerCase() ?? ''; - return pod.includes(token.value); - } - - if (token.key === 'compose') { - const project = container.podman?.composeProject?.toLowerCase() ?? ''; - const service = container.podman?.composeService?.toLowerCase() ?? ''; - return project.includes(token.value) || service.includes(token.value); - } - - if (token.key === 'state') { - return state.includes(token.value) || health.includes(token.value); - } - - // Special filter for containers with updates available - if (token.key === 'has' && token.value === 'update') { - return container.updateStatus?.updateAvailable === true; - } - - const fields: string[] = [ - container.name, - container.id, - container.image, - container.status, - container.state, - container.health, - host.displayName, - host.hostname, - host.id, - ] - .filter(Boolean) - .map((value) => value!.toLowerCase()); - - if (container.podman) { - [ - container.podman.podName, - container.podman.podId, - container.podman.composeProject, - container.podman.composeService, - container.podman.autoUpdatePolicy, - container.podman.userNamespace, - ] - .filter(Boolean) - .forEach((value) => fields.push(value!.toLowerCase())); - } - - if (container.labels) { - Object.entries(container.labels).forEach(([key, value]) => { - fields.push(key.toLowerCase()); - if (value) fields.push(value.toLowerCase()); - }); - } - - if (container.ports) { - container.ports.forEach((port) => { - const parts = [port.privatePort, port.publicPort, port.protocol, port.ip] - .filter(Boolean) - .map(String) - .join(':') - .toLowerCase(); - if (parts) fields.push(parts); - }); - } - - return fields.some((field) => field.includes(token.value)); -}; - -const serviceMatchesToken = (token: SearchToken, host: DockerHost, service: DockerService) => { - const hostName = toLower(host.customDisplayName ?? host.displayName ?? host.hostname ?? host.id); - const serviceName = toLower(service.name ?? service.id); - const image = toLower(service.image); - - if (token.key === 'name') { - return serviceName.includes(token.value); - } - - if (token.key === 'image') { - return image.includes(token.value); - } - - if (token.key === 'host') { - return hostName.includes(token.value); - } - - if (token.key === 'state') { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - const status = desired > 0 && running >= desired ? 'healthy' : 'degraded'; - return status.includes(token.value); - } - - const fields: string[] = [ - service.name, - service.id, - service.image, - service.stack, - service.mode, - host.displayName, - host.hostname, - host.id, - ] - .filter(Boolean) - .map((value) => value!.toLowerCase()); - - if (service.labels) { - Object.entries(service.labels).forEach(([key, value]) => { - fields.push(key.toLowerCase()); - if (value) fields.push(value.toLowerCase()); - }); - } - - return fields.some((field) => field.includes(token.value)); -}; const serviceHealthBadge = (service: DockerService) => { const desired = service.desiredTasks ?? 0; @@ -2253,8 +2102,6 @@ const DockerUnifiedTable: Component = (props) => { const rowCache = new Map(); const tasksCache = new Map(); - - const tokens = createMemo(() => parseSearchTerm(props.searchTerm)); const [sortKey, setSortKey] = usePersistentSignal('dockerUnifiedSortKey', 'host', { deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'), }); @@ -2318,7 +2165,6 @@ const DockerUnifiedTable: Component = (props) => { const groupedRows = createMemo(() => { const groups: Array<{ host: DockerHost; rows: DockerRow[] }> = []; const filter = props.statsFilter ?? null; - const searchTokens = tokens(); const selectedHostId = props.selectedHostId ? props.selectedHostId() : null; const usedCacheKeys = new Set(); const usedTaskCacheKeys = new Set(); @@ -2350,8 +2196,7 @@ const DockerUnifiedTable: Component = (props) => { containers.forEach((container) => { if (!containerMatchesStateFilter(filter, container)) return; - const matchesSearch = searchTokens.every((token) => containerMatchesToken(token, host, container)); - if (!matchesSearch) return; + if (!containerMatchesDockerSearch(props.searchTerm, host, container)) return; const rowId = container.id || `${host.id}-container-${container.name}`; const cacheKey = `c:${host.id}:${rowId}`; @@ -2373,8 +2218,7 @@ const DockerUnifiedTable: Component = (props) => { services.forEach((service) => { if (!serviceMatchesHealthFilter(filter, service)) return; - const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service)); - if (!matchesSearch) return; + if (!serviceMatchesDockerSearch(props.searchTerm, host, service)) return; let associatedTasks = tasks.filter((task) => { if (service.id && task.serviceId) { diff --git a/frontend-modern/src/components/Docker/__tests__/dockerSearch.test.ts b/frontend-modern/src/components/Docker/__tests__/dockerSearch.test.ts new file mode 100644 index 000000000..495b298a2 --- /dev/null +++ b/frontend-modern/src/components/Docker/__tests__/dockerSearch.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import type { DockerContainer, DockerHost } from '@/types/api'; +import { + containerMatchesDockerSearch, + getDockerHostDisplayName, + parseDockerSearchTerm, +} from '@/components/Docker/dockerSearch'; + +const makeHost = (overrides: Partial = {}): DockerHost => + ({ + id: 'host-1', + hostname: 'prod-host', + displayName: 'Production Host', + containers: [], + services: [], + tasks: [], + ...overrides, + }) as DockerHost; + +const makeContainer = (overrides: Partial = {}): DockerContainer => + ({ + id: 'abc123', + name: 'postgres-db', + image: 'postgres:16', + state: 'running', + status: 'Up 1 hour', + health: 'healthy', + updateStatus: { updateAvailable: true }, + ...overrides, + }) as DockerContainer; + +describe('dockerSearch', () => { + it('parses keyed and free-text search tokens', () => { + expect(parseDockerSearchTerm('image:postgres host:prod running')).toEqual([ + { key: 'image', value: 'postgres' }, + { key: 'host', value: 'prod' }, + { value: 'running' }, + ]); + }); + + it('matches updateable containers by keyed filters', () => { + const host = makeHost(); + const container = makeContainer(); + + expect(containerMatchesDockerSearch('image:postgres', host, container)).toBe(true); + expect(containerMatchesDockerSearch('host:prod', host, container)).toBe(true); + expect(containerMatchesDockerSearch('state:running', host, container)).toBe(true); + expect(containerMatchesDockerSearch('has:update', host, container)).toBe(true); + expect(containerMatchesDockerSearch('image:redis', host, container)).toBe(false); + }); + + it('uses the same host display name fallback as the docker table', () => { + expect(getDockerHostDisplayName(makeHost({ customDisplayName: 'Friendly Host' }))).toBe('Friendly Host'); + expect(getDockerHostDisplayName(makeHost({ customDisplayName: '', displayName: 'Display Host' }))).toBe('Display Host'); + expect(getDockerHostDisplayName(makeHost({ customDisplayName: '', displayName: '', hostname: 'raw-host' }))).toBe('raw-host'); + }); +}); diff --git a/frontend-modern/src/components/Docker/dockerSearch.ts b/frontend-modern/src/components/Docker/dockerSearch.ts new file mode 100644 index 000000000..1df41b956 --- /dev/null +++ b/frontend-modern/src/components/Docker/dockerSearch.ts @@ -0,0 +1,179 @@ +import type { DockerContainer, DockerHost, DockerService } from '@/types/api'; + +type SearchToken = { key?: string; value: string }; + +const toLower = (value?: string | null) => value?.toLowerCase() ?? ''; + +export const getDockerHostDisplayName = (host: DockerHost): string => + host.customDisplayName || host.displayName || host.hostname || host.id || ''; + +export const parseDockerSearchTerm = (term?: string): SearchToken[] => { + if (!term) return []; + return term + .trim() + .split(/\s+/) + .filter(Boolean) + .map((token) => { + const [rawKey, ...rest] = token.split(':'); + if (rest.length === 0) { + return { value: token.toLowerCase() }; + } + return { key: rawKey.toLowerCase(), value: rest.join(':').toLowerCase() }; + }); +}; + +const containerMatchesSearchToken = ( + token: SearchToken, + host: DockerHost, + container: DockerContainer, +) => { + const state = toLower(container.state); + const health = toLower(container.health); + const hostName = toLower(getDockerHostDisplayName(host)); + + if (token.key === 'name') { + return ( + toLower(container.name).includes(token.value) || + toLower(container.id).includes(token.value) + ); + } + + if (token.key === 'image') { + return toLower(container.image).includes(token.value); + } + + if (token.key === 'host') { + return hostName.includes(token.value); + } + + if (token.key === 'pod') { + const pod = container.podman?.podName?.toLowerCase() ?? ''; + return pod.includes(token.value); + } + + if (token.key === 'compose') { + const project = container.podman?.composeProject?.toLowerCase() ?? ''; + const service = container.podman?.composeService?.toLowerCase() ?? ''; + return project.includes(token.value) || service.includes(token.value); + } + + if (token.key === 'state') { + return state.includes(token.value) || health.includes(token.value); + } + + if (token.key === 'has' && token.value === 'update') { + return container.updateStatus?.updateAvailable === true; + } + + const fields: string[] = [ + container.name, + container.id, + container.image, + container.status, + container.state, + container.health, + host.displayName, + host.hostname, + host.id, + ] + .filter(Boolean) + .map((value) => value!.toLowerCase()); + + if (container.podman) { + [ + container.podman.podName, + container.podman.podId, + container.podman.composeProject, + container.podman.composeService, + container.podman.autoUpdatePolicy, + container.podman.userNamespace, + ] + .filter(Boolean) + .forEach((value) => fields.push(value!.toLowerCase())); + } + + if (container.labels) { + Object.entries(container.labels).forEach(([key, value]) => { + fields.push(key.toLowerCase()); + if (value) fields.push(value.toLowerCase()); + }); + } + + if (container.ports) { + container.ports.forEach((port) => { + const parts = [port.privatePort, port.publicPort, port.protocol, port.ip] + .filter(Boolean) + .map(String) + .join(':') + .toLowerCase(); + if (parts) fields.push(parts); + }); + } + + return fields.some((field) => field.includes(token.value)); +}; + +const serviceMatchesSearchToken = (token: SearchToken, host: DockerHost, service: DockerService) => { + const hostName = toLower(getDockerHostDisplayName(host)); + const serviceName = toLower(service.name ?? service.id); + const image = toLower(service.image); + + if (token.key === 'name') { + return serviceName.includes(token.value); + } + + if (token.key === 'image') { + return image.includes(token.value); + } + + if (token.key === 'host') { + return hostName.includes(token.value); + } + + if (token.key === 'state') { + const desired = service.desiredTasks ?? 0; + const running = service.runningTasks ?? 0; + const status = desired > 0 && running >= desired ? 'healthy' : 'degraded'; + return status.includes(token.value); + } + + const fields: string[] = [ + service.name, + service.id, + service.image, + service.stack, + service.mode, + host.displayName, + host.hostname, + host.id, + ] + .filter(Boolean) + .map((value) => value!.toLowerCase()); + + if (service.labels) { + Object.entries(service.labels).forEach(([key, value]) => { + fields.push(key.toLowerCase()); + if (value) fields.push(value.toLowerCase()); + }); + } + + return fields.some((field) => field.includes(token.value)); +}; + +export const containerMatchesDockerSearch = ( + term: string | undefined, + host: DockerHost, + container: DockerContainer, +) => { + const tokens = parseDockerSearchTerm(term); + return tokens.every((token) => containerMatchesSearchToken(token, host, container)); +}; + +export const serviceMatchesDockerSearch = ( + term: string | undefined, + host: DockerHost, + service: DockerService, +) => { + const tokens = parseDockerSearchTerm(term); + return tokens.every((token) => serviceMatchesSearchToken(token, host, service)); +};