Add Kubernetes networking native table

This commit is contained in:
rcourtman 2026-05-24 14:21:25 +01:00
parent 0d22db1a27
commit ecd3e4d377
12 changed files with 633 additions and 14 deletions

View file

@ -182,7 +182,10 @@ through a dedicated native tab, not only the overview stack, while retaining the
shared `PlatformSectionTabs` shell. Kubernetes storage inventory must likewise
stay on the shared tab, toolbar, table, table-alignment, and inline-detail
primitives while the unified-resource owner supplies StorageClass,
PersistentVolume, and PersistentVolumeClaim-specific columns.
PersistentVolume, and PersistentVolumeClaim-specific columns. Kubernetes
networking inventory follows that same primitive boundary while the
unified-resource owner supplies Service, Ingress, and EndpointSlice-specific
columns.
1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
The panel may own shell placement and local action layout, but

View file

@ -63,8 +63,9 @@ truth for live infrastructure data.
39. `internal/models/converters.go`
40. `internal/models/deepcopy.go`
41. `internal/mock/generator.go`
42. `internal/kubernetesagent/agent.go`
43. `pkg/agents/kubernetes/report.go`
42. `internal/mock/demo_scenarios.go`
43. `internal/kubernetesagent/agent.go`
44. `pkg/agents/kubernetes/report.go`
## Shared Boundaries
@ -197,11 +198,12 @@ truth for live infrastructure data.
the report contract. Mock/demo Kubernetes ConfigMap and Secret inventory
must mirror the current metadata-only trust boundary rather than seeding
payload key names. Mock/demo Kubernetes inventory must also seed
representative storage-class, persistent-volume, and persistent-volume-claim
rows so the native storage tab exercises the same report/resource contract
as live agents. Monitoring must preserve those objects as native cluster
representative Service, Ingress, EndpointSlice, storage-class,
persistent-volume, and persistent-volume-claim rows so the native
networking and storage tabs exercise the same report/resource contract as
live agents. Monitoring must preserve those objects as native cluster
inventory instead of flattening them into pods, deployments, or generic
storage/configuration rows.
networking, storage, or configuration rows.
## Forbidden Paths

View file

@ -130,6 +130,7 @@ cross-source deduplication.
106. `frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx`
107. `frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx`
108. `frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx`
109. `frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx`
## Shared Boundaries
@ -278,6 +279,13 @@ reintroduce false Swarm capability surfaces.
or phase, class, size/request, access/reclaim policy, provisioner, and
PV/PVC binding targets instead of routing those API objects through the
generic Kubernetes inventory table.
Kubernetes networking inventory follows the same native-table rule:
`/kubernetes/networking` must render Service, Ingress, and EndpointSlice
rows through a networking-specific table that preserves cluster/namespace
scope, Service type, ClusterIP/external IPs, service ports, selectors,
Ingress class, hosts and addresses, EndpointSlice address type, ready
endpoint counts, endpoint ports, and service targets instead of routing
those API objects through the generic Kubernetes inventory table.
2. Add typed accessors and views in `internal/unifiedresources/views.go`
3. Add source ingestion/adaptation in the adapter layer only
Frontend resource platform contracts in

View file

@ -31,7 +31,6 @@ import type { Resource } from '@/types/resource';
type KubernetesInventoryVariant =
| 'controllers'
| 'services'
| 'networking'
| 'config'
| 'policy'
| 'autoscaling'
@ -57,8 +56,6 @@ const tableTitle = (variant: KubernetesInventoryVariant, explicit?: string): str
return 'Controllers';
case 'services':
return 'Services';
case 'networking':
return 'Networking';
case 'config':
return 'Config';
case 'policy':
@ -150,7 +147,7 @@ const KubernetesInventoryHeader: Component<{ variant: KubernetesInventoryVariant
>
Namespace
</TableHead>
<Show when={props.variant === 'services' || props.variant === 'networking'}>
<Show when={props.variant === 'services'}>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>Type</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[20%]`}
@ -409,7 +406,7 @@ const KubernetesInventoryRow: Component<{
>
{namespace()}
</TableCell>
<Show when={props.variant === 'services' || props.variant === 'networking'}>
<Show when={props.variant === 'services'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{networkType()}
</TableCell>

View file

@ -0,0 +1,352 @@
import { For, Show, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
const textValue = (value: string | undefined): string => asTrimmedString(value) || '—';
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;
};
const scopeLabel = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const summarizeValues = (
values: readonly (string | undefined)[] | undefined,
visible = 2,
): { label: string; title: string } => {
const normalized = (values ?? [])
.map((value) => asTrimmedString(value))
.filter((value): value is string => typeof value === 'string' && value.length > 0);
if (normalized.length === 0) return { label: '—', title: '' };
const shown = normalized.slice(0, visible);
const suffix = normalized.length > shown.length ? ` +${normalized.length - shown.length}` : '';
return { label: `${shown.join(', ')}${suffix}`, title: normalized.join(', ') };
};
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) => {
if (!port.port) return undefined;
const protocol = port.protocol ? `/${port.protocol.toLowerCase()}` : '';
const appProtocol = port.appProtocol ? ` ${port.appProtocol}` : '';
return `${port.port}${protocol}${appProtocol}`;
}),
);
}
return { label: '—', title: '' };
};
const typeOrClass = (resource: Resource): string =>
textValue(
resource.kubernetes?.serviceType ||
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 ?? []),
...(resource.kubernetes?.addresses ?? []),
]);
}
const ready = resource.kubernetes?.readyEndpointCount;
const total = resource.kubernetes?.endpointCount;
if (typeof ready === 'number' || typeof total === 'number') {
const readyValue = ready ?? 0;
const totalValue = total ?? readyValue;
return {
label: `${readyValue}/${totalValue} ready`,
title: `${readyValue}/${totalValue} ready`,
};
}
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);
const hostCount = resource.kubernetes?.hosts?.length ?? 0;
const label =
typeof rules === 'number'
? `${rules} rule${rules === 1 ? '' : 's'}`
: hostCount > 0
? `${hostCount} host${hostCount === 1 ? '' : 's'}`
: hosts.label;
return { label, title: hosts.title || label };
}
const service = textValue(resource.kubernetes?.serviceName);
const ready = resource.kubernetes?.readyEndpointCount;
const total = resource.kubernetes?.endpointCount;
const endpointLabel =
typeof ready === 'number' || typeof total === 'number'
? `${ready ?? 0}/${total ?? ready ?? 0} ready`
: '';
return {
label: [service, endpointLabel].filter((value) => value && value !== '—').join(' · ') || '—',
title: [service, endpointLabel].filter((value) => value && value !== '—').join(' · '),
};
};
export const KubernetesNetworkingTable: Component<{
resources: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
title?: string;
showToolbar?: boolean;
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-networking-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources);
return (
<Show
when={props.resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search networking inventory"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="network resources"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No network resources match current filters"
description="Adjust the search or status filter to see more Kubernetes network resources."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Services, Ingresses, and EndpointSlices'} />
<Table class="min-w-full table-fixed text-xs md:min-w-[1180px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[19%]`}>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
Type / class
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
Address / hosts
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
Ports
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
>
Targets
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => resourceName(resource);
const scope = () => scopeLabel(resource);
const address = () => addressOrHosts(resource);
const ports = () => portLabel(resource);
const targets = () => targetSummary(resource);
const detailRowId = () => drawer.detailRowId(resource);
const isExpanded = () => drawer.isExpanded(resource);
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-kubernetes-networking-row={resource.id}
onClick={() => drawer.toggle(resource)}
onKeyDown={drawer.handleActivationKey(resource)}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={resource.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{networkKind(resource)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-[12rem] truncate" title={scope()}>
{scope()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{typeOrClass(resource)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[16rem] truncate"
title={address().title}
>
{address().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-[12rem] truncate" title={ports().title}>
{ports().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[13rem] truncate"
title={targets().title}
>
{targets().label}
</span>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={7}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource)}
/>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default KubernetesNetworkingTable;

View file

@ -16,6 +16,7 @@ import {
import { KubernetesClustersTable } from './KubernetesClustersTable';
import { KubernetesDeploymentsTable } from './KubernetesDeploymentsTable';
import { KubernetesInventoryTable } from './KubernetesInventoryTable';
import { KubernetesNetworkingTable } from './KubernetesNetworkingTable';
import { KubernetesNodesTable } from './KubernetesNodesTable';
import { KubernetesStorageTable } from './KubernetesStorageTable';
import {
@ -128,9 +129,8 @@ export function KubernetesPageSurface() {
/>
</Show>
<Show when={activeTab() === 'networking'}>
<KubernetesInventoryTable
<KubernetesNetworkingTable
resources={model().networking}
variant="networking"
emptyIcon={k8sIcon()}
emptyTitle="No networking resources reported"
emptyDescription="Services, ingresses, and endpoint slices appear here once the agent can read networking inventory."

View file

@ -0,0 +1,95 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { KubernetesNetworkingTable } from '../KubernetesNetworkingTable';
const makeResource = ({
id,
type,
...overrides
}: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource => ({
id,
name: id,
displayName: id,
platformId: 'cluster-1',
platformType: 'kubernetes',
sourceType: 'agent',
sources: ['kubernetes'],
status: 'online',
type,
lastSeen: 1_700_000_000_000,
...overrides,
});
afterEach(() => {
cleanup();
});
describe('KubernetesNetworkingTable', () => {
it('renders Service, 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',
kubernetes: {
clusterId: 'cluster-1',
namespace: 'apps',
resourceKind: 'Ingress',
className: 'nginx',
hosts: ['shop.example.com'],
ingressRuleCount: 2,
},
}),
makeResource({
id: 'checkout-api-abc12',
type: 'k8s-endpoint-slice',
kubernetes: {
clusterId: 'cluster-1',
namespace: 'services',
resourceKind: 'EndpointSlice',
addressType: 'IPv4',
serviceName: 'checkout-api',
endpointCount: 3,
readyEndpointCount: 3,
endpointPorts: [{ name: 'http', protocol: 'TCP', port: 8080 }],
},
}),
]}
emptyIcon={<span />}
emptyTitle="No networking"
emptyDescription="No networking"
showToolbar={false}
/>
));
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.getAllByText('3/3 ready')).toHaveLength(1);
expect(screen.getByText('checkout-api · 3/3 ready')).toBeInTheDocument();
});
});

View file

@ -58,6 +58,10 @@ vi.mock('../KubernetesDeploymentsTable', () => ({
KubernetesDeploymentsTable: () => <div data-testid="deployments-table" />,
}));
vi.mock('../KubernetesNetworkingTable', () => ({
KubernetesNetworkingTable: () => <div data-testid="networking-table" />,
}));
vi.mock('../KubernetesStorageTable', () => ({
KubernetesStorageTable: () => <div data-testid="storage-table" />,
}));
@ -264,4 +268,61 @@ describe('KubernetesPageSurface contract', () => {
expect(screen.getByTestId('storage-table')).toBeInTheDocument();
expect(screen.queryByTestId('kubernetes-workloads-surface')).not.toBeInTheDocument();
});
it('renders Kubernetes networking inventory through the networking-native table', () => {
mockPathname.mockReturnValue('/kubernetes/networking');
mockUseUnifiedResources.mockReturnValue({
resources: () => [
{
id: 'checkout-api',
type: 'k8s-service',
name: 'checkout-api',
displayName: 'checkout-api',
platformId: 'cluster-1',
platformType: 'kubernetes',
sourceType: 'agent',
sources: ['kubernetes'],
status: 'online',
lastSeen: 1_700_000_000_000,
},
],
loading: () => false,
error: () => null,
refetch: vi.fn(),
});
mockUseWorkloadsState.mockReturnValue({
allGuests: () => [],
clearPinnedSummaryScope: vi.fn(),
containerRuntimeFilterConfig: () => undefined,
focusedSummaryWorkloadGroupId: () => null,
groupingMode: () => 'grouped',
handleBeforeAutoFocus: vi.fn(),
search: () => '',
selectedGuestId: () => null,
setGroupingMode: vi.fn(),
setMetricDisplayMode: vi.fn(),
setSearch: vi.fn(),
setSortDirection: vi.fn(),
setSortKey: vi.fn(),
setStatusMode: vi.fn(),
setViewMode: vi.fn(),
setWorkloadMetricHistoryRange: vi.fn(),
statusMode: () => 'all',
surfaceConnected: () => false,
surfaceInitialDataReceived: () => false,
viewMode: () => 'pod',
workloadMetricDisplayMode: () => 'bars',
workloadMetricHistoryRange: () => '1h',
workloadsFilterColumnVisibility: () => undefined,
});
render(() => (
<Router>
<Route path="/" component={KubernetesPageSurface} />
</Router>
));
expect(screen.getByTestId('networking-table')).toBeInTheDocument();
expect(screen.queryByTestId('kubernetes-workloads-surface')).not.toBeInTheDocument();
});
});

View file

@ -645,6 +645,9 @@ func applyDemoKubernetesNativeInventory(cluster *models.KubernetesCluster, now t
cluster.EndpointSlices = []models.KubernetesEndpointSlice{
{UID: cluster.ID + "-eps-checkout-api", Name: "checkout-api-abc12", Namespace: "services", AddressType: "IPv4", ServiceName: "checkout-api", EndpointCount: 3, ReadyEndpointCount: 3, Ports: []models.KubernetesEndpointPort{{Name: "http", Protocol: "TCP", Port: 8080, AppProtocol: "kubernetes.io/http"}}, CreatedAt: createdAt, Labels: labels("checkout-api")},
}
cluster.Ingresses = []models.KubernetesIngress{
{UID: cluster.ID + "-ing-checkout-web", Name: "checkout-web", Namespace: "apps", ClassName: "nginx", Hosts: []string{"checkout.demo.pulse.local"}, Addresses: []string{"198.51.100.24"}, CreatedAt: createdAt, Labels: labels("checkout-web")},
}
allowExpansion := true
cluster.StorageClasses = []models.KubernetesStorageClass{
{UID: cluster.ID + "-sc-fast-ssd", Name: "fast-ssd", Provisioner: "csi.pulse-demo.local", ReclaimPolicy: "Delete", VolumeBindingMode: "WaitForFirstConsumer", AllowVolumeExpansion: &allowExpansion, ParameterKeys: []string{"type", "iops", "encrypted"}, CreatedAt: createdAt, Labels: labels("fast-ssd")},

View file

@ -1309,6 +1309,7 @@ func generateKubernetesClusters(config MockConfig) []models.KubernetesCluster {
nodes := generateKubernetesNodes(clusterID, nodeCount)
pods := generateKubernetesPods(clusterID, nodes, podCount)
deployments := generateKubernetesDeployments(clusterID, deploymentCount)
services, ingresses, endpointSlices := generateKubernetesNetworkingInventory(clusterID, now)
storageClasses, persistentVolumes, persistentVolumeClaims := generateKubernetesStorageInventory(clusterID, now)
lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second)
@ -1333,6 +1334,9 @@ func generateKubernetesClusters(config MockConfig) []models.KubernetesCluster {
Nodes: nodes,
Pods: pods,
Deployments: deployments,
Services: services,
Ingresses: ingresses,
EndpointSlices: endpointSlices,
PersistentVolumes: persistentVolumes,
PersistentVolumeClaims: persistentVolumeClaims,
StorageClasses: storageClasses,
@ -1346,6 +1350,62 @@ func generateKubernetesClusters(config MockConfig) []models.KubernetesCluster {
return clusters
}
func generateKubernetesNetworkingInventory(
clusterID string,
now time.Time,
) ([]models.KubernetesService, []models.KubernetesIngress, []models.KubernetesEndpointSlice) {
createdAt := now.Add(-68 * time.Hour)
labels := map[string]string{
"app.kubernetes.io/name": "checkout-api",
"app.kubernetes.io/part-of": "pulse-demo-estate",
}
services := []models.KubernetesService{
{
UID: clusterID + "-svc-checkout-api",
Name: "checkout-api",
Namespace: "services",
ServiceType: "ClusterIP",
ClusterIP: "10.96.18.24",
Ports: []models.KubernetesServicePort{
{Name: "http", Protocol: "TCP", Port: 8080, TargetPort: "8080"},
},
Selector: map[string]string{"app.kubernetes.io/name": "checkout-api"},
CreatedAt: createdAt,
Labels: cloneStringMap(labels),
},
}
ingresses := []models.KubernetesIngress{
{
UID: clusterID + "-ing-checkout-api",
Name: "checkout-api",
Namespace: "services",
ClassName: "nginx",
Hosts: []string{"checkout.example.test"},
Addresses: []string{"198.51.100.24"},
CreatedAt: createdAt.Add(10 * time.Minute),
Labels: cloneStringMap(labels),
},
}
endpointSlices := []models.KubernetesEndpointSlice{
{
UID: clusterID + "-eps-checkout-api",
Name: "checkout-api-abc12",
Namespace: "services",
AddressType: "IPv4",
ServiceName: "checkout-api",
EndpointCount: 3,
ReadyEndpointCount: 3,
Ports: []models.KubernetesEndpointPort{
{Name: "http", Protocol: "TCP", Port: 8080, AppProtocol: "kubernetes.io/http"},
},
CreatedAt: createdAt.Add(15 * time.Minute),
Labels: cloneStringMap(labels),
},
}
return services, ingresses, endpointSlices
}
func generateKubernetesStorageInventory(
clusterID string,
now time.Time,

View file

@ -429,6 +429,30 @@ func TestBuildFixtureStateIncludesKubernetesStorageInventory(t *testing.T) {
}
}
func TestBuildFixtureStateIncludesKubernetesNetworkingInventory(t *testing.T) {
cfg := DefaultConfig
cfg.K8sClusterCount = 1
cfg.K8sNodesPerCluster = 3
cfg.K8sPodsPerCluster = 8
cfg.RandomMetrics = false
data := buildFixtureState(cfg)
if len(data.KubernetesClusters) != 1 {
t.Fatalf("expected exactly one kubernetes cluster, got %d", len(data.KubernetesClusters))
}
cluster := data.KubernetesClusters[0]
if len(cluster.Services) == 0 {
t.Fatal("expected kubernetes service inventory")
}
if len(cluster.Ingresses) == 0 {
t.Fatal("expected kubernetes ingress inventory")
}
if len(cluster.EndpointSlices) == 0 {
t.Fatal("expected kubernetes endpoint slice inventory")
}
}
func TestMockStateIncludesHostAgents(t *testing.T) {
SetEnabled(true)
t.Cleanup(func() {

View file

@ -421,6 +421,9 @@ func assertKubernetesMockCoverage(t *testing.T, graph FixtureGraph) {
nodeCount := 0
deploymentCount := 0
podCount := 0
serviceCount := 0
ingressCount := 0
endpointSliceCount := 0
storageClassCount := 0
persistentVolumeCount := 0
persistentVolumeClaimCount := 0
@ -428,6 +431,9 @@ func assertKubernetesMockCoverage(t *testing.T, graph FixtureGraph) {
nodeCount += len(cluster.Nodes)
deploymentCount += len(cluster.Deployments)
podCount += len(cluster.Pods)
serviceCount += len(cluster.Services)
ingressCount += len(cluster.Ingresses)
endpointSliceCount += len(cluster.EndpointSlices)
storageClassCount += len(cluster.StorageClasses)
persistentVolumeCount += len(cluster.PersistentVolumes)
persistentVolumeClaimCount += len(cluster.PersistentVolumeClaims)
@ -440,6 +446,14 @@ func assertKubernetesMockCoverage(t *testing.T, graph FixtureGraph) {
podCount,
)
}
if serviceCount == 0 || ingressCount == 0 || endpointSliceCount == 0 {
t.Fatalf(
"expected kubernetes fixtures to include services, ingresses, and endpoint slices; got services=%d ingresses=%d endpointSlices=%d",
serviceCount,
ingressCount,
endpointSliceCount,
)
}
if storageClassCount == 0 || persistentVolumeCount == 0 || persistentVolumeClaimCount == 0 {
t.Fatalf(
"expected kubernetes fixtures to include storage classes, persistent volumes, and persistent volume claims; got classes=%d pvs=%d pvcs=%d",