From 27e72ec8b4e542dbe2136dac3774b4de0fd11b54 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 24 May 2026 20:37:52 +0100 Subject: [PATCH] Fix platform IA review findings --- .../src/features/docker/DockerPageSurface.tsx | 71 +++++--- .../docker/DockerStorageUsageTable.tsx | 23 +-- .../__tests__/DockerPageSurface.test.tsx | 155 +++++++++++++++++- .../docker/__tests__/dockerPageModel.test.ts | 86 ++++++++++ .../src/features/docker/dockerPageModel.ts | 34 +++- .../kubernetes/KubernetesControllersTable.tsx | 11 ++ .../kubernetes/KubernetesNetworkingTable.tsx | 36 +--- .../kubernetes/KubernetesPageSurface.tsx | 9 +- .../KubernetesControllersTable.test.tsx | 22 ++- .../KubernetesNetworkingTable.test.tsx | 20 +-- .../KubernetesPageSurface.contract.test.tsx | 19 ++- 11 files changed, 378 insertions(+), 108 deletions(-) diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index af50d4f22..e2c8ca0a4 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -20,8 +20,10 @@ import { DockerSwarmNodesTable } from './DockerSwarmNodesTable'; import { DockerTasksTable } from './DockerTasksTable'; import { DockerVolumesTable } from './DockerVolumesTable'; import { - DOCKER_TAB_SPECS, buildDockerPageModel, + getDockerPageTabSpecs, + hasDockerEngineStorageUsage, + hasDockerSwarmInventory, resolveDockerPageTabId, type DockerPageModel, type DockerPageTabId, @@ -39,16 +41,20 @@ export function DockerPageSurface() { cacheKey: 'docker-workspace', initialHydration: 'prefer-ws-then-rest', }); - const activeTab = createMemo(() => { + const requestedTab = createMemo(() => { const segment = location.pathname.split('/').filter(Boolean)[1]; return resolveDockerPageTabId(segment); }); const model = createMemo(() => buildDockerPageModel(resources())); + const tabs = createMemo(() => getDockerPageTabSpecs(model())); + const activeTab = createMemo(() => + tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview', + ); return (
@@ -128,34 +134,47 @@ export function DockerPageSurface() { export default DockerPageSurface; function DockerStorage(props: { model: DockerPageModel }) { + const hasEngineUsage = createMemo(() => props.model.hosts.some(hasDockerEngineStorageUsage)); + const hasStorageInventory = createMemo( + () => hasEngineUsage() || props.model.volumes.length > 0, + ); + return ( -
- - -
+ + } + > +
+ + + + 0}> + + +
+
); } function DockerSwarm(props: { model: DockerPageModel }) { - const hasSwarmInventory = createMemo( - () => - props.model.services.length > 0 || - props.model.tasks.length > 0 || - props.model.nodes.length > 0 || - props.model.secrets.length > 0 || - props.model.configs.length > 0, - ); + const hasSwarmInventory = createMemo(() => hasDockerSwarmInventory(props.model)); return ( - Boolean( - bucket && - ((bucket.totalCount ?? 0) > 0 || - (bucket.activeCount ?? 0) > 0 || - (bucket.totalSizeBytes ?? 0) > 0 || - (bucket.reclaimableBytes ?? 0) > 0), - ); - -const hasEngineStorageUsage = (host: Resource): boolean => - hasStorageBucket(host.docker?.imagesUsage) || - hasStorageBucket(host.docker?.containersUsage) || - hasStorageBucket(host.docker?.volumesUsage) || - hasStorageBucket(host.docker?.buildCacheUsage); +import { + hasDockerEngineStorageUsage, + hasDockerStorageUsageBucket, +} from './dockerPageModel'; const bucketValue = (bucket?: DockerStorageUsageMeta): JSX.Element => { - if (!hasStorageBucket(bucket)) return ; + if (!hasDockerStorageUsageBucket(bucket)) return ; const totalSize = bucket?.totalSizeBytes ?? 0; const reclaimable = bucket?.reclaimableBytes ?? 0; const count = bucket?.totalCount ?? 0; @@ -66,7 +55,7 @@ export const DockerStorageUsageTable: Component<{ emptyTitle: string; emptyDescription: string; }> = (props) => { - const storageHosts = () => props.hosts.filter(hasEngineStorageUsage); + const storageHosts = () => props.hosts.filter(hasDockerEngineStorageUsage); const tableState = createPlatformTableFilterState({ resources: storageHosts, initialStatus: 'all' as PlatformResourceStatusFilter, diff --git a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx index 13fd0be29..ede25715f 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx @@ -29,6 +29,12 @@ const mocks = vi.hoisted(() => ({
), ), + DockerStorageUsageTable: vi.fn((props: { hosts: Resource[] }) => ( +
+ )), + DockerVolumesTable: vi.fn((props: { resources: Resource[] }) => ( +
+ )), })); vi.mock('@/hooks/useUnifiedResources', () => ({ @@ -51,13 +57,35 @@ vi.mock('../DockerHostsTable', () => ({ DockerHostsTable: mocks.DockerHostsTable, })); +vi.mock('../DockerStorageUsageTable', () => ({ + DockerStorageUsageTable: mocks.DockerStorageUsageTable, +})); + +vi.mock('../DockerVolumesTable', () => ({ + DockerVolumesTable: mocks.DockerVolumesTable, +})); + vi.mock('@/features/platformPage/sharedPlatformPage', async () => { const actual = await vi.importActual( '@/features/platformPage/sharedPlatformPage', ); return { ...actual, - PlatformSectionTabs: () =>
, + PlatformSectionTabs: (props: { + active: string; + tabs: Array<{ id: string; label: string; path: string }>; + }) => ( +
tab.id).join(',')} + /> + ), + PlatformTableEmptyState: (props: { title: string; description: string }) => ( +
+ {props.description} +
+ ), }; }); @@ -97,6 +125,23 @@ const makeDockerContainer = (overrides: Partial = {}): Resource => ({ ...overrides, }); +const makeDockerVolume = (overrides: Partial = {}): Resource => ({ + id: 'docker-volume:docker-01:checkout-data', + name: 'checkout-data', + displayName: 'checkout-data', + platformId: 'lab', + platformType: 'docker', + sourceType: 'agent', + status: 'online', + type: 'docker-volume', + lastSeen: 1_700_000_000_000, + docker: { + runtime: 'docker', + driver: 'local', + } as NonNullable, + ...overrides, +}); + beforeEach(() => { mocks.pathname = '/docker/overview'; mocks.useUnifiedResources.mockReturnValue({ @@ -139,6 +184,10 @@ describe('DockerPageSurface', () => { 'data-show-toolbar', 'false', ); + expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute( + 'data-tabs', + 'overview,containers,images,storage,networks', + ); expect(screen.queryByTestId('docker-containers-table')).toBeNull(); }); @@ -156,4 +205,108 @@ describe('DockerPageSurface', () => { 'undefined', ); }); + + it('shows the Swarm tab only when Docker hosts report Swarm evidence', () => { + mocks.useUnifiedResources.mockReturnValue({ + error: () => null, + loading: () => false, + refetch: vi.fn(), + resources: () => [ + makeDockerHost({ + docker: { + runtime: 'docker', + swarm: { + nodeId: 'node-1', + nodeRole: 'manager', + localState: 'active', + }, + } as NonNullable, + }), + ], + }); + + render(() => ); + + expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute( + 'data-tabs', + 'overview,containers,images,storage,networks,swarm', + ); + }); + + it('falls back to Overview when the Swarm route is requested without Swarm evidence', () => { + mocks.pathname = '/docker/swarm'; + + render(() => ); + + expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute('data-active', 'overview'); + expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute( + 'data-resource-count', + '1', + ); + }); + + it('renders only volume storage inventory when engine storage usage is absent', () => { + mocks.pathname = '/docker/storage'; + mocks.useUnifiedResources.mockReturnValue({ + error: () => null, + loading: () => false, + refetch: vi.fn(), + resources: () => [makeDockerHost(), makeDockerVolume()], + }); + + render(() => ); + + expect(screen.queryByTestId('docker-storage-usage-table')).toBeNull(); + expect(screen.getByTestId('docker-volumes-table')).toHaveAttribute('data-resource-count', '1'); + expect(screen.queryByTestId('platform-table-empty-state')).toBeNull(); + }); + + it('renders only engine storage usage when volume inventory is absent', () => { + mocks.pathname = '/docker/storage'; + mocks.useUnifiedResources.mockReturnValue({ + error: () => null, + loading: () => false, + refetch: vi.fn(), + resources: () => [ + makeDockerHost({ + docker: { + runtime: 'docker', + imagesUsage: { + totalCount: 1, + totalSizeBytes: 1024, + }, + } as NonNullable, + }), + ], + }); + + render(() => ); + + expect(screen.getByTestId('docker-storage-usage-table')).toHaveAttribute( + 'data-host-count', + '1', + ); + expect(screen.queryByTestId('docker-volumes-table')).toBeNull(); + expect(screen.queryByTestId('platform-table-empty-state')).toBeNull(); + }); + + it('uses one Storage tab empty state when no storage inventory exists', () => { + mocks.pathname = '/docker/storage'; + mocks.useUnifiedResources.mockReturnValue({ + error: () => null, + loading: () => false, + refetch: vi.fn(), + resources: () => [makeDockerHost()], + }); + + render(() => ); + + expect(screen.queryByTestId('docker-storage-usage-table')).toBeNull(); + expect(screen.queryByTestId('docker-volumes-table')).toBeNull(); + expect(screen.getAllByTestId('platform-table-empty-state')).toHaveLength(1); + expect(screen.getByTestId('platform-table-empty-state')).toHaveAttribute( + 'data-title', + 'No Docker or Podman storage inventory', + ); + }); }); diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index c96406e3e..5b532d913 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -5,7 +5,10 @@ import { DOCKER_TAB_SPECS, buildDockerPageModel, getDockerHostSystemBadge, + getDockerPageTabSpecs, + hasDockerEngineStorageUsage, hasDockerSwarmEvidence, + hasDockerSwarmInventory, resolveDockerPageTabId, } from '../dockerPageModel'; @@ -183,4 +186,87 @@ describe('dockerPageModel', () => { ), ).toBe(true); }); + + it('derives the visible Docker workflow tabs from Swarm evidence', () => { + const nonSwarmModel = buildDockerPageModel([ + makeResource({ + id: 'docker-host-1', + type: 'agent', + docker: { + runtime: 'docker', + swarm: { + nodeRole: 'worker', + localState: 'inactive', + }, + }, + }), + ]); + const swarmModel = buildDockerPageModel([ + makeResource({ + id: 'docker-host-1', + type: 'agent', + docker: { + runtime: 'docker', + swarm: { + nodeId: 'node-1', + nodeRole: 'manager', + localState: 'active', + }, + }, + }), + ]); + + expect(hasDockerSwarmInventory(nonSwarmModel)).toBe(false); + expect(getDockerPageTabSpecs(nonSwarmModel).map((tab) => tab.id)).toEqual([ + 'overview', + 'containers', + 'images', + 'storage', + 'networks', + ]); + expect(hasDockerSwarmInventory(swarmModel)).toBe(true); + expect(getDockerPageTabSpecs(swarmModel).map((tab) => tab.id)).toEqual([ + 'overview', + 'containers', + 'images', + 'storage', + 'networks', + 'swarm', + ]); + }); + + it('detects engine storage usage only from populated disk-usage buckets', () => { + expect( + hasDockerEngineStorageUsage( + makeResource({ + id: 'docker-host-empty', + type: 'agent', + docker: { + runtime: 'docker', + imagesUsage: { + totalCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0, + }, + }, + }), + ), + ).toBe(false); + + expect( + hasDockerEngineStorageUsage( + makeResource({ + id: 'docker-host-storage', + type: 'agent', + docker: { + runtime: 'docker', + buildCacheUsage: { + totalCount: 1, + totalSizeBytes: 1024, + }, + }, + }), + ), + ).toBe(true); + }); }); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 9345e80b3..5012a58d8 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -1,5 +1,5 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; -import type { Resource, ResourceType } from '@/types/resource'; +import type { DockerStorageUsageMeta, Resource, ResourceType } from '@/types/resource'; import { getInfrastructureSystemIdentityBadges, type ResourceBadge, @@ -24,11 +24,13 @@ export type DockerPageTabId = | 'networks' | 'swarm'; -export const DOCKER_TAB_SPECS: readonly { +export type DockerTabSpec = { id: DockerPageTabId; label: string; path: string; -}[] = [ +}; + +export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [ // Keep the runtime lens at operator-workflow granularity. Overview owns // runtime hosts; detailed object inventory belongs in the Containers, // Images, Storage, Networks, and Swarm workflows so the page does not repeat @@ -101,6 +103,32 @@ export type DockerPageModel = { configs: Resource[]; }; +export const hasDockerStorageUsageBucket = (bucket?: DockerStorageUsageMeta): boolean => + Boolean( + bucket && + ((bucket.totalCount ?? 0) > 0 || + (bucket.activeCount ?? 0) > 0 || + (bucket.totalSizeBytes ?? 0) > 0 || + (bucket.reclaimableBytes ?? 0) > 0), + ); + +export const hasDockerEngineStorageUsage = (host: Resource): boolean => + hasDockerStorageUsageBucket(host.docker?.imagesUsage) || + hasDockerStorageUsageBucket(host.docker?.containersUsage) || + hasDockerStorageUsageBucket(host.docker?.volumesUsage) || + hasDockerStorageUsageBucket(host.docker?.buildCacheUsage); + +export const hasDockerSwarmInventory = (model: DockerPageModel): boolean => + model.hosts.some(hasDockerSwarmEvidence) || + model.services.length > 0 || + model.tasks.length > 0 || + model.nodes.length > 0 || + model.secrets.length > 0 || + model.configs.length > 0; + +export const getDockerPageTabSpecs = (model: DockerPageModel): readonly DockerTabSpec[] => + DOCKER_TAB_SPECS.filter((tab) => tab.id !== 'swarm' || hasDockerSwarmInventory(model)); + const RUNTIME_ONLY_SYSTEM_LABELS = new Set(['docker', 'docker / podman', 'podman']); export const getDockerHostSystemBadge = (host: Resource): ResourceBadge | undefined => diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index 7328fbce5..dbf10fea2 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -50,6 +50,7 @@ const controllerScope = (resource: Resource): string => { const targetValue = (resource: Resource): string => { switch (resource.type) { + case 'k8s-replicaset': case 'k8s-statefulset': return `${resource.kubernetes?.desiredReplicas ?? 0} pods`; case 'k8s-daemonset': @@ -90,6 +91,7 @@ const readyOrDoneValue = (resource: Resource): number | undefined => { const availableValue = (resource: Resource): number | undefined => { switch (resource.type) { + case 'k8s-replicaset': case 'k8s-statefulset': return resource.kubernetes?.availableReplicas; case 'k8s-daemonset': @@ -101,6 +103,7 @@ const availableValue = (resource: Resource): number | undefined => { const exceptionSummary = (resource: Resource): string => { switch (resource.type) { + case 'k8s-replicaset': case 'k8s-statefulset': { const desired = resource.kubernetes?.desiredReplicas ?? 0; const ready = resource.kubernetes?.readyReplicas ?? 0; @@ -126,6 +129,14 @@ const exceptionSummary = (resource: Resource): string => { const apiDetail = (resource: Resource): string => { switch (resource.type) { + case 'k8s-replicaset': { + if (typeof resource.kubernetes?.fullyLabeledReplicas === 'number') { + return `Fully labeled: ${resource.kubernetes.fullyLabeledReplicas}`; + } + return typeof resource.kubernetes?.observedGeneration === 'number' + ? `Observed: ${resource.kubernetes.observedGeneration}` + : '—'; + } case 'k8s-statefulset': return resource.kubernetes?.serviceName ? `Service: ${resource.kubernetes.serviceName}` : '—'; case 'k8s-daemonset': diff --git a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx index 176dab864..429352b9f 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx @@ -39,7 +39,6 @@ const resourceName = (resource: Resource): string => asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id; const networkKind = (resource: Resource): string => { - if (resource.type === 'k8s-service') return 'Service'; if (resource.type === 'k8s-ingress') return 'Ingress'; if (resource.type === 'k8s-endpoint-slice') return 'EndpointSlice'; return resource.kubernetes?.resourceKind || resource.type; @@ -68,17 +67,6 @@ const summarizeValues = ( }; const portLabel = (resource: Resource): { label: string; title: string } => { - if (resource.kubernetes?.servicePorts?.length) { - return summarizeValues( - resource.kubernetes.servicePorts.map((port) => { - if (!port.port) return undefined; - const protocol = port.protocol ? `/${port.protocol.toLowerCase()}` : ''; - const target = port.targetPort ? `:${port.targetPort}` : ''; - const nodePort = port.nodePort ? ` node:${port.nodePort}` : ''; - return `${port.port}${target}${protocol}${nodePort}`; - }), - ); - } if (resource.kubernetes?.endpointPorts?.length) { return summarizeValues( resource.kubernetes.endpointPorts.map((port) => { @@ -93,19 +81,9 @@ const portLabel = (resource: Resource): { label: string; title: string } => { }; const typeOrClass = (resource: Resource): string => - textValue( - resource.kubernetes?.serviceType || - resource.kubernetes?.className || - resource.kubernetes?.addressType, - ); + textValue(resource.kubernetes?.className || resource.kubernetes?.addressType); const addressOrHosts = (resource: Resource): { label: string; title: string } => { - if (resource.type === 'k8s-service') { - return summarizeValues([ - resource.kubernetes?.clusterIp, - ...(resource.kubernetes?.externalIps ?? []), - ]); - } if (resource.type === 'k8s-ingress') { return summarizeValues([ ...(resource.kubernetes?.hosts ?? []), @@ -125,17 +103,7 @@ const addressOrHosts = (resource: Resource): { label: string; title: string } => return summarizeValues(resource.kubernetes?.addresses); }; -const selectorSummary = (resource: Resource): { label: string; title: string } => { - const selector = resource.kubernetes?.selector; - if (!selector || Object.keys(selector).length === 0) return { label: '—', title: '' }; - const pairs = Object.entries(selector) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => `${key}=${value}`); - return summarizeValues(pairs, 2); -}; - const targetSummary = (resource: Resource): { label: string; title: string } => { - if (resource.type === 'k8s-service') return selectorSummary(resource); if (resource.type === 'k8s-ingress') { const rules = resource.kubernetes?.ingressRuleCount; const hosts = summarizeValues(resource.kubernetes?.hosts); @@ -214,7 +182,7 @@ export const KubernetesNetworkingTable: Component<{ } > - + diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index e901d350f..3e9d52089 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -137,6 +137,7 @@ interface KubernetesOverviewProps { } const getKubernetesControllerResources = (model: KubernetesPageModel): Resource[] => [ + ...model.replicaSets, ...model.statefulSets, ...model.daemonSets, ...model.jobs, @@ -145,11 +146,7 @@ const getKubernetesControllerResources = (model: KubernetesPageModel): Resource[ function KubernetesWorkloads(props: { model: KubernetesPageModel; controllers: Resource[] }) { const hasWorkloadInventory = createMemo( - () => - props.model.pods.length > 0 || - props.model.deployments.length > 0 || - props.controllers.length > 0 || - props.model.autoscaling.length > 0, + () => props.model.workloads.length > 0 || props.model.autoscaling.length > 0, ); return ( @@ -185,7 +182,7 @@ function KubernetesWorkloads(props: { model: KubernetesPageModel; controllers: R resources={props.controllers} emptyIcon={k8sIcon()} emptyTitle="No workload controllers reported" - emptyDescription="StatefulSets, DaemonSets, Jobs, and CronJobs appear here when the agent reports them." + emptyDescription="ReplicaSets, StatefulSets, DaemonSets, Jobs, and CronJobs appear here when the agent reports them." /> 0}> diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx index b963fb97b..ec09e0486 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx @@ -27,10 +27,24 @@ afterEach(() => { }); describe('KubernetesControllersTable', () => { - it('renders native controller fields for StatefulSet, DaemonSet, Job, and CronJob rows', () => { + it('renders native controller fields for ReplicaSet, StatefulSet, DaemonSet, Job, and CronJob rows', () => { render(() => ( { expect(screen.getByText('Exceptions')).toBeInTheDocument(); expect(screen.getByText('Detail')).toBeInTheDocument(); + expect(screen.getByText('ReplicaSet')).toBeInTheDocument(); + expect(screen.getByText('4 pods')).toBeInTheDocument(); + expect(screen.getAllByText('1 not ready')).toHaveLength(2); + expect(screen.getByText('Fully labeled: 4')).toBeInTheDocument(); + expect(screen.getByText('StatefulSet')).toBeInTheDocument(); expect(screen.getByText('3 pods')).toBeInTheDocument(); - expect(screen.getByText('1 not ready')).toBeInTheDocument(); expect(screen.getByText('Service: checkout-headless')).toBeInTheDocument(); expect(screen.getByText('DaemonSet')).toBeInTheDocument(); diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesNetworkingTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesNetworkingTable.test.tsx index 262bcba95..cf218f31c 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesNetworkingTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesNetworkingTable.test.tsx @@ -27,23 +27,10 @@ afterEach(() => { }); describe('KubernetesNetworkingTable', () => { - it('renders Service, Ingress, and EndpointSlice fields from the Kubernetes networking APIs', () => { + it('renders Ingress and EndpointSlice fields from the Kubernetes networking APIs', () => { render(() => ( { expect(screen.getByText('Type / class')).toBeInTheDocument(); expect(screen.getByText('Address / hosts')).toBeInTheDocument(); expect(screen.getByText('Targets')).toBeInTheDocument(); - expect(screen.getByText('ClusterIP')).toBeInTheDocument(); - expect(screen.getByText('10.96.18.24')).toBeInTheDocument(); - expect(screen.getAllByText('8080:8080/tcp')).toHaveLength(1); - expect(screen.getByText('app=checkout-api')).toBeInTheDocument(); expect(screen.getByText('nginx')).toBeInTheDocument(); expect(screen.getByText('shop.example.com')).toBeInTheDocument(); expect(screen.getByText('2 rules')).toBeInTheDocument(); expect(screen.getByText('IPv4')).toBeInTheDocument(); + expect(screen.getByText('8080/tcp')).toBeInTheDocument(); expect(screen.getAllByText('3/3 ready')).toHaveLength(1); expect(screen.getByText('checkout-api · 3/3 ready')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx index 5fe18c037..d2e7893e8 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx @@ -165,6 +165,11 @@ describe('KubernetesPageSurface contract', () => { query: expect.stringContaining('k8s-deployment'), }), ); + expect(mockUseUnifiedResources).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining('k8s-replicaset'), + }), + ); expect(mockUseUnifiedResources).toHaveBeenCalledWith( expect.objectContaining({ query: expect.stringContaining('k8s-statefulset'), @@ -186,6 +191,7 @@ describe('KubernetesPageSurface contract', () => { setResources([ makeResource({ id: 'checkout-api', type: 'pod' }), makeResource({ id: 'checkout-deployment', type: 'k8s-deployment' }), + makeResource({ id: 'checkout-replicaset', type: 'k8s-replicaset' }), makeResource({ id: 'checkout-stateful', type: 'k8s-statefulset' }), makeResource({ id: 'checkout-hpa', type: 'k8s-horizontal-pod-autoscaler' }), ]); @@ -195,10 +201,21 @@ describe('KubernetesPageSurface contract', () => { expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'workloads'); expect(screen.getByTestId('pods-table')).toHaveAttribute('data-rows', '1'); expect(screen.getByTestId('deployments-table')).toHaveAttribute('data-rows', '1'); - expect(screen.getByTestId('controllers-table')).toHaveAttribute('data-rows', '1'); + expect(screen.getByTestId('controllers-table')).toHaveAttribute('data-rows', '2'); expect(screen.getByTestId('autoscaling-table')).toHaveAttribute('data-rows', '1'); }); + it('routes ReplicaSets to the rendered workload controllers table', () => { + mockPathname.mockReturnValue('/kubernetes/workloads'); + setResources([makeResource({ id: 'checkout-replicaset', type: 'k8s-replicaset' })]); + + renderSurface(); + + expect(screen.getByTestId('controllers-table')).toHaveAttribute('data-rows', '1'); + expect(screen.queryByTestId('pods-table')).toBeNull(); + expect(screen.queryByTestId('deployments-table')).toBeNull(); + }); + it('groups Services, ingress, and endpoints under the Services tab without duplicating services in networking', () => { mockPathname.mockReturnValue('/kubernetes/services'); setResources([