mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 17:02:10 +00:00
Add Kubernetes networking native table
This commit is contained in:
parent
0d22db1a27
commit
ecd3e4d377
12 changed files with 633 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue