diff --git a/frontend-modern/src/features/docker/DockerConfigsTable.tsx b/frontend-modern/src/features/docker/DockerConfigsTable.tsx index 8ea484e06..da66e3f57 100644 --- a/frontend-modern/src/features/docker/DockerConfigsTable.tsx +++ b/frontend-modern/src/features/docker/DockerConfigsTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -29,12 +27,13 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; +import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; export const DockerConfigsTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); return ( diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 45cf30a64..57ea7e323 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -18,10 +18,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -31,7 +29,12 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; -import { compareDockerContainers, mapDockerContainerStatus } from './dockerPageModel'; +import { + compareDockerContainers, + filterDockerResources, + mapDockerContainerStatus, + type DockerResourceStatusFilter, +} from './dockerPageModel'; import type { Resource } from '@/types/resource'; type DockerPort = NonNullable['ports']>[number]; @@ -119,82 +122,11 @@ const updateStatusTitle = (resource: Resource): string => { ); }; -const dockerContainerSearchCandidates = (resource: Resource): Array => [ - resource.name, - resource.displayName, - resource.id, - resource.parentName, - resource.platformId, - resource.platformType, - resource.agent?.hostname, - resource.identity?.hostname, - resource.canonicalIdentity?.displayName, - resource.canonicalIdentity?.hostname, - resource.canonicalIdentity?.primaryId, - ...(resource.canonicalIdentity?.aliases ?? []), - resource.docker?.containerId, - resource.docker?.displayName, - resource.docker?.hostname, - resource.docker?.runtime, - resource.docker?.runtimeVersion, - resource.docker?.dockerVersion, - resource.docker?.image, - resource.docker?.imageId, - resource.docker?.containerState, - resource.docker?.health, - numberSearchValue(resource.docker?.restartCount), - numberSearchValue(resource.docker?.exitCode), - updateStatusLabel(resource), - resource.docker?.updateStatus?.error, - resource.docker?.updateStatus?.currentDigest, - resource.docker?.updateStatus?.latestDigest, - ...(resource.docker?.ports?.flatMap((port) => [ - portLabel(port), - port.ip, - numberSearchValue(port.privatePort), - numberSearchValue(port.publicPort), - port.protocol, - ]) ?? []), - ...(resource.docker?.networks?.flatMap((network) => [ - networkLabel(network), - network.name, - network.ipv4, - network.ipv6, - ]) ?? []), - ...(resource.docker?.mounts?.flatMap((mount) => [ - mountLabel(mount), - mount.type, - mount.source, - mount.destination, - mount.mode, - mount.rw === false ? 'read-only' : mount.rw === true ? 'read-write' : undefined, - ]) ?? []), - ...(resource.tags ?? []), -]; - -const matchesDockerContainerSearch = (resource: Resource, search: string): boolean => { - const needle = search.trim().toLowerCase(); - if (!needle) return true; - - return dockerContainerSearchCandidates(resource) - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .some((value) => value.toLowerCase().includes(needle)); -}; - -const filterDockerContainerResources = ( - resources: Resource[], - search: string, - status: PlatformResourceStatusFilter, -): Resource[] => - filterPlatformResources(resources, '', status).filter((resource) => - matchesDockerContainerSearch(resource, search), - ); - export const DockerContainersTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterDockerContainerResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerContainers)); diff --git a/frontend-modern/src/features/docker/DockerHostsTable.tsx b/frontend-modern/src/features/docker/DockerHostsTable.tsx index 6752d90c2..943a80ab6 100644 --- a/frontend-modern/src/features/docker/DockerHostsTable.tsx +++ b/frontend-modern/src/features/docker/DockerHostsTable.tsx @@ -26,14 +26,17 @@ import { PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Disk } from '@/types/api'; import type { Resource } from '@/types/resource'; -import { getDockerHostSystemBadge, hasDockerSwarmEvidence } from './dockerPageModel'; +import { + filterDockerResources, + getDockerHostSystemBadge, + hasDockerSwarmEvidence, + type DockerResourceStatusFilter, +} from './dockerPageModel'; // Docker / Podman hosts are container hosts, not generic Pulse Agents. // The operator columns that matter are runtime version, container count, @@ -106,8 +109,8 @@ export const DockerHostsTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); const showSwarmColumn = createMemo(() => props.resources.some(hasDockerSwarmEvidence)); const [selectedHostId, setSelectedHostId] = createSignal(null); diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index 8b7e97883..25ebfe8fc 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -30,12 +28,13 @@ import { dockerNumberValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; +import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; export const DockerImagesTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); return ( diff --git a/frontend-modern/src/features/docker/DockerNetworksTable.tsx b/frontend-modern/src/features/docker/DockerNetworksTable.tsx index 74c5fb865..a5ab0d27f 100644 --- a/frontend-modern/src/features/docker/DockerNetworksTable.tsx +++ b/frontend-modern/src/features/docker/DockerNetworksTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -29,6 +27,7 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; +import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; import type { Resource } from '@/types/resource'; const networkFlags = (resource: Resource): string => @@ -61,8 +60,8 @@ const networkSubnets = (resource: Resource): string => export const DockerNetworksTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); return ( diff --git a/frontend-modern/src/features/docker/DockerSecretsTable.tsx b/frontend-modern/src/features/docker/DockerSecretsTable.tsx index 6010a7b76..2dff3d478 100644 --- a/frontend-modern/src/features/docker/DockerSecretsTable.tsx +++ b/frontend-modern/src/features/docker/DockerSecretsTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -29,12 +27,13 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; +import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; export const DockerSecretsTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); return ( diff --git a/frontend-modern/src/features/docker/DockerServicesTable.tsx b/frontend-modern/src/features/docker/DockerServicesTable.tsx index ec735b0bc..d1230d0ae 100644 --- a/frontend-modern/src/features/docker/DockerServicesTable.tsx +++ b/frontend-modern/src/features/docker/DockerServicesTable.tsx @@ -19,13 +19,16 @@ import { PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; -import { compareDockerServices, mapDockerServiceStatus } from './dockerPageModel'; +import { + compareDockerServices, + filterDockerResources, + mapDockerServiceStatus, + type DockerResourceStatusFilter, +} from './dockerPageModel'; // Docker Swarm services are cluster-scoped declarations, not running // processes — they have no CPU / Memory / Disk / Disk I/O / Uptime / @@ -89,8 +92,8 @@ export const DockerServicesTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerServices)); diff --git a/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx b/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx index 13973ba0a..65c0898b4 100644 --- a/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx +++ b/frontend-modern/src/features/docker/DockerStorageUsageTable.tsx @@ -21,15 +21,15 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { DockerStorageUsageMeta, Resource } from '@/types/resource'; import { + filterDockerResources, hasDockerEngineStorageUsage, hasDockerStorageUsageBucket, + type DockerResourceStatusFilter, } from './dockerPageModel'; const bucketValue = (bucket?: DockerStorageUsageMeta): JSX.Element => { @@ -58,8 +58,8 @@ export const DockerStorageUsageTable: Component<{ const storageHosts = () => props.hosts.filter(hasDockerEngineStorageUsage); const tableState = createPlatformTableFilterState({ resources: storageHosts, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); const hasFilteredSourceRows = () => (props.sourceCount ?? props.hosts.length) > 0; diff --git a/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx b/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx index 11e0b1bd5..cc9a22580 100644 --- a/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx +++ b/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -29,13 +27,18 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; -import { compareDockerSwarmNodes, mapDockerSwarmNodeStatus } from './dockerPageModel'; +import { + compareDockerSwarmNodes, + filterDockerResources, + mapDockerSwarmNodeStatus, + type DockerResourceStatusFilter, +} from './dockerPageModel'; export const DockerSwarmNodesTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerSwarmNodes)); diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx index 7ff2dcf76..466d13086 100644 --- a/frontend-modern/src/features/docker/DockerTasksTable.tsx +++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -28,13 +26,18 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; -import { compareDockerTasks, mapDockerTaskStatus } from './dockerPageModel'; +import { + compareDockerTasks, + filterDockerResources, + mapDockerTaskStatus, + type DockerResourceStatusFilter, +} from './dockerPageModel'; export const DockerTasksTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerTasks)); diff --git a/frontend-modern/src/features/docker/DockerVolumesTable.tsx b/frontend-modern/src/features/docker/DockerVolumesTable.tsx index b258ccdd2..3de5b2d81 100644 --- a/frontend-modern/src/features/docker/DockerVolumesTable.tsx +++ b/frontend-modern/src/features/docker/DockerVolumesTable.tsx @@ -17,10 +17,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, @@ -29,12 +27,13 @@ import { dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; +import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; export const DockerVolumesTable: Component = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as DockerResourceStatusFilter, + filter: filterDockerResources, }); return ( diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index 95b4f37bb..3a7802ce1 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -8,6 +8,7 @@ import { compareDockerServices, compareDockerSwarmNodes, compareDockerTasks, + filterDockerResources, getDockerHostSystemBadge, getDockerPageTabSpecs, hasDockerEngineStorageUsage, @@ -631,4 +632,78 @@ describe('dockerPageModel', () => { expect(model.services.map((r) => r.id)).toEqual(['svc-down', 'svc-ok']); }); }); + + describe('filterDockerResources', () => { + const rows: Resource[] = [ + makeResource({ id: 'web', type: 'app-container', status: 'online' }), + makeResource({ + id: 'edge-proxy', + type: 'app-container', + status: 'online', + docker: { + hostname: 'edge-01', + image: 'traefik:v3.1', + runtime: 'docker', + runtimeVersion: '27.5.1', + containerState: 'running', + ports: [{ ip: '0.0.0.0', publicPort: 8080, privatePort: 80, protocol: 'tcp' }], + }, + }), + makeResource({ + id: 'svc-payments', + type: 'docker-service', + status: 'degraded', + docker: { + serviceName: 'payments-worker', + swarm: { clusterName: 'prod-swarm', nodeRole: 'manager' }, + }, + }), + makeResource({ + id: 'redis-vol', + type: 'docker-volume', + status: 'online', + docker: { volumeName: 'redis-data', driver: 'local' }, + }), + ]; + + it('matches docker.* fields the shared filter no longer carries', () => { + expect(filterDockerResources(rows, 'edge-01', 'all').map((r) => r.id)).toEqual([ + 'edge-proxy', + ]); + expect(filterDockerResources(rows, 'traefik', 'all').map((r) => r.id)).toEqual([ + 'edge-proxy', + ]); + expect(filterDockerResources(rows, 'payments-worker', 'all').map((r) => r.id)).toEqual([ + 'svc-payments', + ]); + expect(filterDockerResources(rows, 'prod-swarm', 'all').map((r) => r.id)).toEqual([ + 'svc-payments', + ]); + expect(filterDockerResources(rows, 'redis-data', 'all').map((r) => r.id)).toEqual([ + 'redis-vol', + ]); + }); + + it('matches composite port tokens like host:public->private/proto', () => { + expect(filterDockerResources(rows, '0.0.0.0:8080->80/tcp', 'all').map((r) => r.id)).toEqual([ + 'edge-proxy', + ]); + }); + + it('still combines status and search', () => { + expect( + filterDockerResources(rows, 'payments-worker', 'degraded').map((r) => r.id), + ).toEqual(['svc-payments']); + expect(filterDockerResources(rows, 'payments-worker', 'online')).toEqual([]); + }); + + it('returns all rows for empty search and triad filters by status', () => { + expect(filterDockerResources(rows, '', 'all').map((r) => r.id).sort()).toEqual( + ['edge-proxy', 'redis-vol', 'svc-payments', 'web'].sort(), + ); + expect(filterDockerResources(rows, '', 'degraded').map((r) => r.id)).toEqual([ + 'svc-payments', + ]); + }); + }); }); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 5af41a5dd..644a95ee4 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -209,6 +209,146 @@ export const compareDockerServices = compareByDockerStatus(mapDockerServiceStatu export const compareDockerTasks = compareByDockerStatus(mapDockerTaskStatus); export const compareDockerSwarmNodes = compareByDockerStatus(mapDockerSwarmNodeStatus); +export type DockerResourceStatusFilter = 'all' | 'online' | 'degraded' | 'offline'; + +const ONLINE_STATUSES = new Set(['online', 'running']); +const DEGRADED_STATUSES = new Set(['degraded', 'paused']); +const OFFLINE_STATUSES = new Set(['offline', 'stopped']); + +const mapResourceStatusToTriad = ( + status: string | undefined, +): Exclude | 'unknown' => { + if (!status) return 'unknown'; + if (ONLINE_STATUSES.has(status)) return 'online'; + if (DEGRADED_STATUSES.has(status)) return 'degraded'; + if (OFFLINE_STATUSES.has(status)) return 'offline'; + return 'unknown'; +}; + +const numberToken = (value: number | undefined): string | undefined => + typeof value === 'number' ? String(value) : undefined; + +const dockerPortToken = ( + port: NonNullable['ports']>[number], +): string => { + const protocol = asTrimmedString(port.protocol)?.toLowerCase() || 'tcp'; + const privatePort = numberToken(port.privatePort); + const publicPort = numberToken(port.publicPort); + const ip = asTrimmedString(port.ip); + if (privatePort && publicPort) { + return `${ip ? `${ip}:` : ''}${publicPort}->${privatePort}/${protocol}`; + } + if (privatePort) return `${privatePort}/${protocol}`; + if (publicPort) return `${ip ? `${ip}:` : ''}${publicPort}/${protocol}`; + return protocol; +}; + +// Builds the lowercase search haystack a Docker page table consults when +// filtering rows. The shared platformPage helper carries only generic Resource +// fields; docker.* lookups live here so the cross-platform helper does not +// have to know which platforms exist. +export function dockerResourceSearchHaystack(resource: Resource): string { + const docker = resource.docker; + return [ + resource.id, + resource.name, + resource.displayName, + resource.parentName, + resource.platformId, + resource.platformType, + resource.agent?.hostname, + resource.identity?.hostname, + resource.canonicalIdentity?.displayName, + resource.canonicalIdentity?.hostname, + resource.canonicalIdentity?.primaryId, + ...(resource.canonicalIdentity?.aliases ?? []), + docker?.hostname, + docker?.displayName, + docker?.runtime, + docker?.runtimeVersion, + docker?.dockerVersion, + docker?.containerId, + docker?.image, + docker?.imageId, + docker?.containerState, + docker?.health, + numberToken(docker?.restartCount), + numberToken(docker?.exitCode), + docker?.updateStatus?.error, + docker?.updateStatus?.currentDigest, + docker?.updateStatus?.latestDigest, + docker?.volumeName, + docker?.networkId, + docker?.driver, + docker?.mountpoint, + docker?.serviceName, + docker?.taskId, + docker?.nodeId, + docker?.nodeName, + docker?.nodeRole, + docker?.availability, + docker?.address, + docker?.managerReachability, + docker?.managerAddress, + docker?.engineVersion, + docker?.secretName, + docker?.configName, + docker?.mode, + docker?.currentState, + docker?.desiredState, + docker?.message, + docker?.error, + docker?.swarm?.clusterId, + docker?.swarm?.clusterName, + docker?.swarm?.nodeRole, + docker?.swarm?.localState, + ...(docker?.ports?.flatMap((port) => [ + dockerPortToken(port), + port.ip, + port.protocol, + numberToken(port.privatePort), + numberToken(port.publicPort), + ]) ?? []), + ...(docker?.networks?.flatMap((network) => [network.name, network.ipv4, network.ipv6]) ?? []), + ...(docker?.mounts?.flatMap((mount) => [ + mount.type, + mount.source, + mount.destination, + mount.mode, + mount.rw === false ? 'read-only' : mount.rw === true ? 'read-write' : undefined, + ]) ?? []), + ...(docker?.repoTags ?? []), + ...(docker?.repoDigests ?? []), + ...(resource.tags ?? []), + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') + .toLowerCase(); +} + +export function filterDockerResources( + resources: Resource[], + search: string, + status: DockerResourceStatusFilter, +): Resource[] { + const needle = search.trim().toLowerCase(); + const result: Resource[] = []; + for (const resource of resources) { + if (status !== 'all') { + const triad = mapResourceStatusToTriad(resource.status); + if (triad !== status) continue; + } + if (!needle) { + result.push(resource); + continue; + } + if (dockerResourceSearchHaystack(resource).includes(needle)) { + result.push(resource); + } + } + return result; +} + export function hasDockerSwarmEvidence(resource: Resource): boolean { const swarm = resource.docker?.swarm; if (!swarm) return false; diff --git a/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx index 263221bf0..b20b490ae 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx @@ -20,12 +20,14 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; +import { + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; const textValue = (value: string | undefined): string => asTrimmedString(value) || '—'; @@ -86,8 +88,8 @@ export const KubernetesAutoscalingTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); return ( diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx index 14c100875..330e1f3f1 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx @@ -23,10 +23,8 @@ import { PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailTableRow, @@ -35,7 +33,11 @@ import { getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { Resource } from '@/types/resource'; -import { buildKubernetesClusterChildCounts } from './kubernetesPageModel'; +import { + buildKubernetesClusterChildCounts, + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; // Kubernetes clusters are control-plane aggregates, not single processes — // they have no per-cluster Disk I/O / Uptime / Temperature concepts that @@ -72,8 +74,8 @@ export const KubernetesClustersTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.clusters, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-cluster-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope); diff --git a/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx index 41ad08a14..cb6a898f2 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx @@ -20,12 +20,14 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; +import { + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; // ConfigMaps and Secrets are intentionally rendered as API metadata. Pulse may // know that keys exist, but metadata-only collection must never imply that @@ -157,8 +159,8 @@ export const KubernetesConfigTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); return ( diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index be76a52d3..4398ae3eb 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -20,15 +20,15 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; import { compareKubernetesControllers, + filterKubernetesResources, mapKubernetesControllerStatus, + type KubernetesResourceStatusFilter, } from './kubernetesPageModel'; const textValue = (value: string | undefined): string => asTrimmedString(value) || '—'; @@ -173,8 +173,8 @@ export const KubernetesControllersTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareKubernetesControllers), diff --git a/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx index 45d322ce1..856b530d8 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx @@ -20,15 +20,15 @@ import { PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; import { compareKubernetesDeployments, + filterKubernetesResources, mapKubernetesDeploymentStatus, + type KubernetesResourceStatusFilter, } from './kubernetesPageModel'; // Kubernetes Deployments are scheduling abstractions over their controlled @@ -57,8 +57,8 @@ export const KubernetesDeploymentsTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareKubernetesDeployments), diff --git a/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx index 2cef91979..f748e8f22 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx @@ -19,13 +19,16 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; -import { compareKubernetesEvents, mapKubernetesEventSeverity } from './kubernetesPageModel'; +import { + compareKubernetesEvents, + filterKubernetesResources, + mapKubernetesEventSeverity, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; const textValue = (value: string | undefined): string => asTrimmedString(value) || '—'; @@ -69,8 +72,8 @@ export const KubernetesEventsTable: Component<{ const sortedEvents = createMemo(() => [...props.resources].sort(compareKubernetesEvents)); const tableState = createPlatformTableFilterState({ resources: sortedEvents, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); return ( diff --git a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx index 429352b9f..d549f86dc 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx @@ -20,10 +20,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailTableRow, @@ -32,6 +30,10 @@ import { getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { Resource } from '@/types/resource'; +import { + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; const textValue = (value: string | undefined): string => asTrimmedString(value) || '—'; @@ -139,8 +141,8 @@ export const KubernetesNetworkingTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-networking-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); diff --git a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx index 9ffe61917..f15032ad6 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx @@ -22,10 +22,8 @@ import { PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailTableRow, @@ -34,7 +32,12 @@ import { getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { Resource } from '@/types/resource'; -import { compareKubernetesNodes, mapKubernetesNodeStatus } from './kubernetesPageModel'; +import { + compareKubernetesNodes, + filterKubernetesResources, + mapKubernetesNodeStatus, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; // Kubernetes nodes carry richer Kubelet/runtime metadata than a generic // Pulse Agent — kubelet version, container runtime, roles @@ -96,8 +99,8 @@ export const KubernetesNodesTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-node-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); diff --git a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx index 0128c06d5..fd9d9fbcc 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx @@ -19,13 +19,16 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource, ResourceKubernetesPodContainerStatus } from '@/types/resource'; -import { compareKubernetesPods, mapKubernetesPodStatus } from './kubernetesPageModel'; +import { + compareKubernetesPods, + filterKubernetesResources, + mapKubernetesPodStatus, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; const textValue = (value: string | undefined): string => asTrimmedString(value) || '—'; @@ -96,8 +99,8 @@ export const KubernetesPodsTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareKubernetesPods)); diff --git a/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx index ce05931f9..4cfac8d7b 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx @@ -20,12 +20,14 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; +import { + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; // Kubernetes policy resources carry very different API fields. Keep this table // on the documented NetworkPolicy, PDB, ResourceQuota, and LimitRange shapes @@ -209,8 +211,8 @@ export const KubernetesPolicyTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); return ( diff --git a/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx index a87f41a6f..57f20ec40 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx @@ -20,10 +20,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailTableRow, @@ -32,6 +30,10 @@ import { getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { Resource } from '@/types/resource'; +import { + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; // Services have a distinct API contract from Ingress and EndpointSlice: // operators need the Service type, virtual IP, exposed ports, node ports, and @@ -98,8 +100,8 @@ export const KubernetesServicesTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-service-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); diff --git a/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx index bef1b15a7..411127608 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx @@ -21,10 +21,8 @@ import { PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, - filterPlatformResources, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, - type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailTableRow, @@ -33,6 +31,10 @@ import { getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { Resource } from '@/types/resource'; +import { + filterKubernetesResources, + type KubernetesResourceStatusFilter, +} from './kubernetesPageModel'; // Kubernetes storage is intentionally not rendered by the generic // inventory table. StorageClass, PersistentVolume, and @@ -168,8 +170,8 @@ export const KubernetesStorageTable: Component<{ }> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, - initialStatus: 'all' as PlatformResourceStatusFilter, - filter: filterPlatformResources, + initialStatus: 'all' as KubernetesResourceStatusFilter, + filter: filterKubernetesResources, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-storage-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); diff --git a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts index 4c4315d7d..493a2e9d6 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts +++ b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts @@ -5,6 +5,7 @@ import { buildKubernetesClusterChildCounts, buildKubernetesPageModel, compareKubernetesControllers, + filterKubernetesResources, compareKubernetesDeployments, compareKubernetesEvents, compareKubernetesNodes, @@ -656,4 +657,68 @@ describe('kubernetesPageModel', () => { ).toBe(0); }); }); + + describe('filterKubernetesResources', () => { + const rows: Resource[] = [ + makeResource({ + id: 'pod-checkout', + type: 'pod', + status: 'online', + kubernetes: { + clusterName: 'prod-cluster', + namespace: 'payments', + podName: 'checkout-api-abc', + ownerKind: 'Deployment', + ownerName: 'checkout-api', + image: 'ghcr.io/pulse-demo/checkout-api:2026.04', + }, + }), + makeResource({ + id: 'svc-checkout', + type: 'k8s-service', + status: 'online', + kubernetes: { serviceType: 'ClusterIP', clusterIp: '10.0.5.4', namespace: 'payments' }, + }), + makeResource({ + id: 'node-worker', + type: 'k8s-node', + status: 'degraded', + kubernetes: { kubeletVersion: 'v1.30.4', roles: ['worker'] }, + }), + makeResource({ + id: 'event-warn', + type: 'k8s-event', + status: 'online', + kubernetes: { eventType: 'Warning', reason: 'BackOff', involvedName: 'checkout-api-abc' }, + }), + ]; + + it('matches kubernetes.* fields the shared filter no longer carries', () => { + expect(filterKubernetesResources(rows, 'payments', 'all').map((r) => r.id).sort()).toEqual( + ['pod-checkout', 'svc-checkout'].sort(), + ); + expect(filterKubernetesResources(rows, 'prod-cluster', 'all').map((r) => r.id)).toEqual([ + 'pod-checkout', + ]); + expect(filterKubernetesResources(rows, 'v1.30.4', 'all').map((r) => r.id)).toEqual([ + 'node-worker', + ]); + expect(filterKubernetesResources(rows, 'BackOff', 'all').map((r) => r.id)).toEqual([ + 'event-warn', + ]); + }); + + it('matches node-role labels via kubernetes.roles', () => { + expect(filterKubernetesResources(rows, 'worker', 'all').map((r) => r.id)).toEqual([ + 'node-worker', + ]); + }); + + it('combines status and search', () => { + expect(filterKubernetesResources(rows, 'v1.30', 'degraded').map((r) => r.id)).toEqual([ + 'node-worker', + ]); + expect(filterKubernetesResources(rows, 'v1.30', 'online')).toEqual([]); + }); + }); }); diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index 55afd1f3c..95bc73839 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -247,6 +247,119 @@ export const compareKubernetesNodes = compareByStatus(mapKubernetesNodeStatus); export const compareKubernetesDeployments = compareByStatus(mapKubernetesDeploymentStatus); export const compareKubernetesControllers = compareByStatus(mapKubernetesControllerStatus); +export type KubernetesResourceStatusFilter = 'all' | 'online' | 'degraded' | 'offline'; + +const ONLINE_RESOURCE_STATUSES = new Set(['online', 'running']); +const DEGRADED_RESOURCE_STATUSES = new Set(['degraded', 'paused']); +const OFFLINE_RESOURCE_STATUSES = new Set(['offline', 'stopped']); + +const mapResourceStatusToTriad = ( + status: string | undefined, +): Exclude | 'unknown' => { + if (!status) return 'unknown'; + if (ONLINE_RESOURCE_STATUSES.has(status)) return 'online'; + if (DEGRADED_RESOURCE_STATUSES.has(status)) return 'degraded'; + if (OFFLINE_RESOURCE_STATUSES.has(status)) return 'offline'; + return 'unknown'; +}; + +// Builds the lowercase search haystack a Kubernetes page table consults when +// filtering rows. The shared platformPage helper carries only generic Resource +// fields; kubernetes.* lookups live here so the cross-platform helper does not +// have to know which platforms exist. +export function kubernetesResourceSearchHaystack(resource: Resource): string { + const k = resource.kubernetes; + return [ + resource.id, + resource.name, + resource.displayName, + resource.parentName, + resource.platformId, + resource.platformType, + resource.agent?.hostname, + resource.identity?.hostname, + resource.canonicalIdentity?.displayName, + resource.canonicalIdentity?.hostname, + resource.canonicalIdentity?.primaryId, + ...(resource.canonicalIdentity?.aliases ?? []), + k?.clusterId, + k?.clusterName, + k?.context, + k?.namespace, + k?.podName, + k?.podPhase, + k?.podReason, + k?.podMessage, + k?.ownerKind, + k?.ownerName, + k?.image, + k?.nodeName, + k?.resourceKind, + k?.serviceName, + k?.serviceType, + k?.clusterIp, + k?.storageClass, + k?.phase, + k?.reason, + k?.message, + k?.involvedKind, + k?.involvedName, + k?.eventType, + k?.volumeName, + k?.version, + k?.kubeletVersion, + k?.containerRuntimeVersion, + k?.osImage, + k?.architecture, + k?.provisioner, + k?.addressType, + k?.secretType, + k?.targetKind, + k?.targetName, + k?.server, + ...(k?.externalIps ?? []), + ...(k?.hosts ?? []), + ...(k?.addresses ?? []), + ...(k?.accessModes ?? []), + ...(k?.roles ?? []), + ...(k?.policyTypes ?? []), + ...(k?.metricTypes ?? []), + ...(k?.podContainers?.flatMap((container) => [ + container.name, + container.image, + container.state, + container.reason, + ]) ?? []), + ...(resource.tags ?? []), + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') + .toLowerCase(); +} + +export function filterKubernetesResources( + resources: Resource[], + search: string, + status: KubernetesResourceStatusFilter, +): Resource[] { + const needle = search.trim().toLowerCase(); + const result: Resource[] = []; + for (const resource of resources) { + if (status !== 'all') { + const triad = mapResourceStatusToTriad(resource.status); + if (triad !== status) continue; + } + if (!needle) { + result.push(resource); + continue; + } + if (kubernetesResourceSearchHaystack(resource).includes(needle)) { + result.push(resource); + } + } + return result; +} + const KUBERNETES_ROUTE_TAB_ALIASES: Record = { autoscaling: 'workloads', config: 'configuration', diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts index 98fa27c66..9ae4a2a14 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -66,34 +66,53 @@ describe('filterPlatformResources', () => { ]); }); - it('searches platform-native metadata used by bespoke tables', () => { + it('searches the platform-native metadata that bespoke tables still consume directly', () => { + // Docker / Kubernetes lookups moved to their per-platform helpers + // (filterDockerResources / filterKubernetesResources) so the shared + // helper stays platform-agnostic. The two providers that still consume + // this filter directly — Proxmox Mail Gateway and the vSphere hosts + // table — keep their native-metadata coverage here. const nativeRows: Resource[] = [ makeResource({ - id: 'docker-host', + id: 'pmg-host', type: 'agent', status: 'online', - docker: { runtimeVersion: '24.0.7', swarm: { nodeRole: 'manager' } }, + pmg: { hostname: 'pmg-primary', version: '8.2.4' }, }), makeResource({ - id: 'k8s-deploy', - type: 'k8s-deployment', + id: 'vsphere-host', + type: 'agent', status: 'online', - kubernetes: { - clusterName: 'prod-cluster', - namespace: 'payments', - containerRuntimeVersion: 'containerd://1.7', - }, + vmware: { clusterName: 'prod-cluster', runtimeHostName: 'esxi-04' }, }), ]; - expect(filterPlatformResources(nativeRows, 'manager', 'all').map((r) => r.id)).toEqual([ - 'docker-host', + expect(filterPlatformResources(nativeRows, 'pmg-primary', 'all').map((r) => r.id)).toEqual([ + 'pmg-host', ]); - expect(filterPlatformResources(nativeRows, 'payments', 'all').map((r) => r.id)).toEqual([ - 'k8s-deploy', + expect(filterPlatformResources(nativeRows, 'prod-cluster', 'all').map((r) => r.id)).toEqual([ + 'vsphere-host', ]); }); + it('no longer matches docker.* or kubernetes.* fields directly', () => { + const dockerOnlyRow = makeResource({ + id: 'docker-host', + type: 'agent', + status: 'online', + docker: { runtimeVersion: '24.0.7', swarm: { nodeRole: 'manager' } }, + }); + const k8sOnlyRow = makeResource({ + id: 'k8s-deploy', + type: 'k8s-deployment', + status: 'online', + kubernetes: { clusterName: 'prod-cluster', namespace: 'payments' }, + }); + + expect(filterPlatformResources([dockerOnlyRow], 'manager', 'all')).toEqual([]); + expect(filterPlatformResources([k8sOnlyRow], 'payments', 'all')).toEqual([]); + }); + it('combines search and status filters', () => { const filtered = filterPlatformResources(resources, 'host', 'degraded'); expect(filtered.map((r) => r.id).sort()).toEqual(['host-charlie', 'host-foxtrot'].sort()); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 5df1cc59a..ce50a1802 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -182,9 +182,13 @@ const mapResourceStatusToTriad = ( return 'unknown'; }; -const platformSearchNumber = (value: number | undefined): string | undefined => - typeof value === 'number' ? String(value) : undefined; - +// Cross-platform fallback haystack used by tables that do not have a +// domain-specific search helper. Docker and Kubernetes provide their own +// platform-page filters (filterDockerResources / filterKubernetesResources) +// that already cover docker.* and kubernetes.* fields, so this helper stays +// platform-agnostic and only knows about the generic Resource surface plus +// the small number of provider blocks that still consume it directly +// (Proxmox Mail Gateway, vSphere hosts table). const matchesPlatformSearch = (resource: Resource, search: string): boolean => { if (!search) return true; const needle = search.trim().toLowerCase(); @@ -202,51 +206,6 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => { resource.canonicalIdentity?.hostname, resource.canonicalIdentity?.primaryId, ...(resource.canonicalIdentity?.aliases ?? []), - resource.docker?.hostname, - resource.docker?.runtime, - resource.docker?.runtimeVersion, - resource.docker?.dockerVersion, - resource.docker?.containerId, - resource.docker?.image, - resource.docker?.imageId, - resource.docker?.containerState, - resource.docker?.health, - platformSearchNumber(resource.docker?.restartCount), - platformSearchNumber(resource.docker?.exitCode), - resource.docker?.updateStatus?.error, - resource.docker?.updateStatus?.currentDigest, - resource.docker?.updateStatus?.latestDigest, - resource.docker?.volumeName, - resource.docker?.networkId, - resource.docker?.driver, - resource.docker?.mountpoint, - resource.docker?.serviceName, - resource.docker?.taskId, - resource.docker?.mode, - resource.docker?.swarm?.clusterName, - resource.docker?.swarm?.nodeRole, - resource.kubernetes?.clusterName, - resource.kubernetes?.clusterId, - resource.kubernetes?.context, - resource.kubernetes?.namespace, - resource.kubernetes?.podName, - resource.kubernetes?.podPhase, - resource.kubernetes?.podReason, - resource.kubernetes?.ownerKind, - resource.kubernetes?.ownerName, - resource.kubernetes?.image, - resource.kubernetes?.nodeName, - resource.kubernetes?.resourceKind, - resource.kubernetes?.serviceType, - resource.kubernetes?.clusterIp, - resource.kubernetes?.storageClass, - resource.kubernetes?.phase, - resource.kubernetes?.reason, - resource.kubernetes?.involvedName, - resource.kubernetes?.volumeName, - resource.kubernetes?.version, - resource.kubernetes?.kubeletVersion, - resource.kubernetes?.containerRuntimeVersion, resource.pmg?.hostname, resource.pmg?.version, resource.vmware?.connectionName, @@ -258,36 +217,6 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => { resource.vmware?.networkType, resource.vmware?.networkHostNames?.join(' '), resource.vmware?.networkVmNames?.join(' '), - ...(resource.docker?.ports?.flatMap((port) => [ - port.ip, - port.protocol, - platformSearchNumber(port.privatePort), - platformSearchNumber(port.publicPort), - ]) ?? []), - ...(resource.docker?.networks?.flatMap((network) => [ - network.name, - network.ipv4, - network.ipv6, - ]) ?? []), - ...(resource.docker?.mounts?.flatMap((mount) => [ - mount.type, - mount.source, - mount.destination, - mount.mode, - mount.rw === false ? 'read-only' : mount.rw === true ? 'read-write' : undefined, - ]) ?? []), - ...(resource.docker?.repoTags ?? []), - ...(resource.docker?.repoDigests ?? []), - ...(resource.kubernetes?.externalIps ?? []), - ...(resource.kubernetes?.hosts ?? []), - ...(resource.kubernetes?.addresses ?? []), - ...(resource.kubernetes?.accessModes ?? []), - ...(resource.kubernetes?.podContainers?.flatMap((container) => [ - container.name, - container.image, - container.state, - container.reason, - ]) ?? []), ...(resource.tags ?? []), ] .filter((value): value is string => typeof value === 'string')