Fix platform IA review findings

This commit is contained in:
rcourtman
2026-05-24 20:37:52 +01:00
parent 6d7ee5d732
commit 27e72ec8b4
11 changed files with 378 additions and 108 deletions
@@ -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<DockerPageTabId>(() => {
const requestedTab = createMemo<DockerPageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1];
return resolveDockerPageTabId(segment);
});
const model = createMemo(() => buildDockerPageModel(resources()));
const tabs = createMemo(() => getDockerPageTabSpecs(model()));
const activeTab = createMemo<DockerPageTabId>(() =>
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
);
return (
<div data-testid="docker-page" class="space-y-3">
<PlatformSectionTabs
tabs={DOCKER_TAB_SPECS}
tabs={tabs()}
active={activeTab()}
ariaLabel="Container runtime sections"
/>
@@ -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 (
<div class="space-y-4">
<DockerStorageUsageTable
hosts={props.model.hosts}
sourceCount={props.model.hosts.length}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman storage usage"
emptyDescription="Engine disk-usage snapshots appear here when a Docker or Podman host reports them."
/>
<DockerVolumesTable
resources={props.model.volumes}
emptyIcon={dockerIcon()}
emptyTitle="No volumes"
emptyDescription="Volumes appear here when the container runtime reports volume inventory."
/>
</div>
<Show
when={hasStorageInventory()}
fallback={
<PlatformTableEmptyState
icon={dockerIcon()}
title="No Docker or Podman storage inventory"
description="Engine disk-usage snapshots and volumes appear here when Docker or Podman hosts report storage inventory."
/>
}
>
<div class="space-y-4">
<Show when={hasEngineUsage()}>
<DockerStorageUsageTable
hosts={props.model.hosts}
sourceCount={props.model.hosts.length}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman storage usage"
emptyDescription="Engine disk-usage snapshots appear here when a Docker or Podman host reports them."
/>
</Show>
<Show when={props.model.volumes.length > 0}>
<DockerVolumesTable
resources={props.model.volumes}
emptyIcon={dockerIcon()}
emptyTitle="No volumes"
emptyDescription="Volumes appear here when the container runtime reports volume inventory."
/>
</Show>
</div>
</Show>
);
}
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 (
<Show
@@ -27,24 +27,13 @@ import {
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { DockerStorageUsageMeta, Resource } from '@/types/resource';
const hasStorageBucket = (bucket?: DockerStorageUsageMeta): boolean =>
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 <span class="text-muted"></span>;
if (!hasDockerStorageUsageBucket(bucket)) return <span class="text-muted"></span>;
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,
@@ -29,6 +29,12 @@ const mocks = vi.hoisted(() => ({
</div>
),
),
DockerStorageUsageTable: vi.fn((props: { hosts: Resource[] }) => (
<div data-testid="docker-storage-usage-table" data-host-count={props.hosts.length} />
)),
DockerVolumesTable: vi.fn((props: { resources: Resource[] }) => (
<div data-testid="docker-volumes-table" data-resource-count={props.resources.length} />
)),
}));
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<typeof import('@/features/platformPage/sharedPlatformPage')>(
'@/features/platformPage/sharedPlatformPage',
);
return {
...actual,
PlatformSectionTabs: () => <div data-testid="docker-section-tabs" />,
PlatformSectionTabs: (props: {
active: string;
tabs: Array<{ id: string; label: string; path: string }>;
}) => (
<div
data-testid="docker-section-tabs"
data-active={props.active}
data-tabs={props.tabs.map((tab) => tab.id).join(',')}
/>
),
PlatformTableEmptyState: (props: { title: string; description: string }) => (
<div data-testid="platform-table-empty-state" data-title={props.title}>
{props.description}
</div>
),
};
});
@@ -97,6 +125,23 @@ const makeDockerContainer = (overrides: Partial<Resource> = {}): Resource => ({
...overrides,
});
const makeDockerVolume = (overrides: Partial<Resource> = {}): 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<Resource['docker']>,
...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<Resource['docker']>,
}),
],
});
render(() => <DockerPageSurface />);
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(() => <DockerPageSurface />);
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(() => <DockerPageSurface />);
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<Resource['docker']>,
}),
],
});
render(() => <DockerPageSurface />);
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(() => <DockerPageSurface />);
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',
);
});
});
@@ -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);
});
});
@@ -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 =>
@@ -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':
@@ -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<{
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Services, Ingresses, and EndpointSlices'} />
<TableCardHeader title={props.title ?? 'Ingresses and EndpointSlices'} />
<Table class="min-w-full table-fixed text-xs md:min-w-[1180px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
@@ -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."
/>
</Show>
<Show when={props.model.autoscaling.length > 0}>
@@ -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(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'checkout-api-replicaset',
type: 'k8s-replicaset',
kubernetes: {
clusterName: 'prod',
namespace: 'apps',
resourceKind: 'ReplicaSet',
desiredReplicas: 4,
currentReplicas: 4,
readyReplicas: 3,
availableReplicas: 3,
fullyLabeledReplicas: 4,
},
}),
makeResource({
id: 'checkout-api-stateful',
type: 'k8s-statefulset',
@@ -102,9 +116,13 @@ describe('KubernetesControllersTable', () => {
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();
@@ -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(() => (
<KubernetesNetworkingTable
resources={[
makeResource({
id: 'checkout-api',
type: 'k8s-service',
kubernetes: {
clusterId: 'cluster-1',
namespace: 'services',
resourceKind: 'Service',
serviceType: 'ClusterIP',
clusterIp: '10.96.18.24',
servicePorts: [{ name: 'http', protocol: 'TCP', port: 8080, targetPort: '8080' }],
selector: { app: 'checkout-api' },
},
}),
makeResource({
id: 'checkout-web',
type: 'k8s-ingress',
@@ -81,14 +68,11 @@ describe('KubernetesNetworkingTable', () => {
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();
});
@@ -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([