mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Lift Docker / Kubernetes search haystacks into the page models
frontend-modern/src/features/platformPage/sharedPlatformPage.tsx carried ~90 lines of resource.docker?.* and resource.kubernetes?.* field knowledge inside matchesPlatformSearch. That coupled the cross-platform helper to two specific platforms — every new docker.* or kubernetes.* field anyone wanted searchable had to be edited in a shared file, and the same fields were duplicated inside DockerContainersTable's bespoke search-candidates helper. dockerPageModel now exports dockerResourceSearchHaystack + filterDockerResources, and kubernetesPageModel exports kubernetesResourceSearchHaystack + filterKubernetesResources, each covering the platform's own generic + meta fields and the composite labels operators are likely to paste (e.g. "0.0.0.0:8080->80/tcp" on container rows). All 12 Docker tables and all 12 Kubernetes tables now import the per-platform filter; the bespoke DockerContainersTable filter helper disappears since the page-model haystack subsumes it. matchesPlatformSearch in sharedPlatformPage drops every docker.* and kubernetes.* reference. The shared helper still serves the four providers that consume it directly without a domain-specific haystack (TrueNAS systems, Proxmox Mail Gateway, vSphere hosts, Agents), so generic + pmg.* + vmware.* + tags stay. Existing sharedPlatformPage test coverage of docker/k8s native metadata moves to the per-platform page-model tests. Verified: - npm run type-check, lint:eslint, lint:theme, lint:canonical-platforms clean - vitest: 138 tests pass across docker / kubernetes / platformPage suites - browser proof on /docker/containers searching "traefik" trims 70 -> 6 by docker.image; on /kubernetes/workloads searching "payments" trims 42 -> 9 by kubernetes.namespace, "production-eu" trims to 14 by kubernetes.clusterName
This commit is contained in:
@@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.resources,
|
||||
initialStatus: 'all' as PlatformResourceStatusFilter,
|
||||
filter: filterPlatformResources,
|
||||
initialStatus: 'all' as DockerResourceStatusFilter,
|
||||
filter: filterDockerResources,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<NonNullable<Resource['docker']>['ports']>[number];
|
||||
@@ -119,82 +122,11 @@ const updateStatusTitle = (resource: Resource): string => {
|
||||
);
|
||||
};
|
||||
|
||||
const dockerContainerSearchCandidates = (resource: Resource): Array<string | undefined> => [
|
||||
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<DockerNativeTableProps> = (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));
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
@@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.resources,
|
||||
initialStatus: 'all' as PlatformResourceStatusFilter,
|
||||
filter: filterPlatformResources,
|
||||
initialStatus: 'all' as DockerResourceStatusFilter,
|
||||
filter: filterDockerResources,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.resources,
|
||||
initialStatus: 'all' as PlatformResourceStatusFilter,
|
||||
filter: filterPlatformResources,
|
||||
initialStatus: 'all' as DockerResourceStatusFilter,
|
||||
filter: filterDockerResources,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.resources,
|
||||
initialStatus: 'all' as PlatformResourceStatusFilter,
|
||||
filter: filterPlatformResources,
|
||||
initialStatus: 'all' as DockerResourceStatusFilter,
|
||||
filter: filterDockerResources,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<DockerNativeTableProps> = (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));
|
||||
|
||||
|
||||
@@ -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<DockerNativeTableProps> = (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));
|
||||
|
||||
|
||||
@@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
resources: () => props.resources,
|
||||
initialStatus: 'all' as PlatformResourceStatusFilter,
|
||||
filter: filterPlatformResources,
|
||||
initialStatus: 'all' as DockerResourceStatusFilter,
|
||||
filter: filterDockerResources,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>(['online', 'running']);
|
||||
const DEGRADED_STATUSES = new Set<string>(['degraded', 'paused']);
|
||||
const OFFLINE_STATUSES = new Set<string>(['offline', 'stopped']);
|
||||
|
||||
const mapResourceStatusToTriad = (
|
||||
status: string | undefined,
|
||||
): Exclude<DockerResourceStatusFilter, 'all'> | '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<NonNullable<Resource['docker']>['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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>(['online', 'running']);
|
||||
const DEGRADED_RESOURCE_STATUSES = new Set<string>(['degraded', 'paused']);
|
||||
const OFFLINE_RESOURCE_STATUSES = new Set<string>(['offline', 'stopped']);
|
||||
|
||||
const mapResourceStatusToTriad = (
|
||||
status: string | undefined,
|
||||
): Exclude<KubernetesResourceStatusFilter, 'all'> | '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<string, KubernetesPageTabId> = {
|
||||
autoscaling: 'workloads',
|
||||
config: 'configuration',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user