Respect docker filters for batch update targets (#1361)

This commit is contained in:
rcourtman
2026-03-25 11:57:38 +00:00
parent 930738593b
commit 73bebf2f4f
4 changed files with 250 additions and 164 deletions
@@ -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<DockerHostsProps> = (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,
@@ -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<string, DockerMetadata>;
@@ -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<DockerUnifiedTableProps> = (props) => {
const rowCache = new Map<string, DockerRow>();
const tasksCache = new Map<string, DockerTask[]>();
const tokens = createMemo(() => parseSearchTerm(props.searchTerm));
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('dockerUnifiedSortKey', 'host', {
deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'),
});
@@ -2318,7 +2165,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (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<string>();
const usedTaskCacheKeys = new Set<string>();
@@ -2350,8 +2196,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (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<DockerUnifiedTableProps> = (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) {
@@ -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> = {}): DockerHost =>
({
id: 'host-1',
hostname: 'prod-host',
displayName: 'Production Host',
containers: [],
services: [],
tasks: [],
...overrides,
}) as DockerHost;
const makeContainer = (overrides: Partial<DockerContainer> = {}): 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');
});
});
@@ -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));
};