mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Fix platform IA review findings
This commit is contained in:
@@ -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}>
|
||||
|
||||
+20
-2
@@ -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();
|
||||
|
||||
+2
-18
@@ -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();
|
||||
});
|
||||
|
||||
+18
-1
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user