mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
Respect docker filters for batch update targets (#1361)
This commit is contained in:
@@ -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));
|
||||
};
|
||||
Reference in New Issue
Block a user