From 72dc2930b7730694fc331a7a3c27dd2fe6c503de Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 16 May 2026 13:10:04 +0100 Subject: [PATCH] k8s(nodes): bespoke nodes table with Kubelet/runtime/capacity columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kubernetes nodes have richer Kubelet/runtime metadata than a generic Pulse Agent — kubelet version, container runtime, roles (control-plane/worker), ready state, pod capacity, capacity vs allocatable CPU/memory. The generic infrastructure table renders the agent metrics fine but omits all that K8s context. Add `KubernetesNodesTable` (under `features/kubernetes/`) that surfaces node + cluster + roles + Kubelet version + container runtime + CPU% + Memory% + capacity (cores / memory bytes) + uptime, reusing canonical shared primitives (Card, Table, SearchInput, FilterButtonGroup, StatusDot). Mount it on `/kubernetes/nodes` in place of the generic infrastructure table. `ResourceKubernetesMeta` extended with the node-only fields the canonical adapter already emits on `k8s-node` rows (and on `agent` rows whose linked host the backend registry merged into a K8s node): `nodeUid`, `kubeletVersion`, `containerRuntimeVersion`, `osImage`, `architecture`, `kernelVersion`, `roles`, `ready`, `capacityCpuCores`, `allocatableCpuCores`, `capacityMemoryBytes`, `allocatableMemoryBytes`, `capacityPods`, `allocatablePods`. Browser verification (Playwright, chromium, live mock-mode): - 9 tests pass; the every-sub-tab operator-controls audit still finds the canonical search input on /kubernetes/nodes (now from the bespoke nodes table's toolbar). Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set. Continues the audit chain (c7bdd11e0 → 5b94724bf → 0fcf9944b). New ResourceKubernetesMeta fields are strictly additive type surfacing of existing canonical payload data; the new bespoke table lives in features/kubernetes/ and reuses canonical primitives only. --- .../kubernetes/KubernetesNodesTable.tsx | 218 ++++++++++++++++++ .../kubernetes/KubernetesPageSurface.tsx | 4 +- frontend-modern/src/types/resource.ts | 18 ++ 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx diff --git a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx new file mode 100644 index 000000000..2b46ceecf --- /dev/null +++ b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx @@ -0,0 +1,218 @@ +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; +import { SearchInput } from '@/components/shared/SearchInput'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +import { getSimpleStatusIndicator } from '@/utils/status'; +import { asTrimmedString } from '@/utils/stringUtils'; +import { + filterPlatformResources, + type PlatformResourceStatusFilter, +} from '@/features/platformPage/sharedPlatformPage'; +import type { Resource } from '@/types/resource'; + +// Kubernetes nodes carry richer Kubelet/runtime metadata than a generic +// Pulse Agent — kubelet version, container runtime, roles +// (control-plane/worker), ready state, pod capacity. They're a hybrid +// row in the canonical model (the registry merges the K8s node onto +// the linked agent host), so the generic infrastructure table renders +// the agent metrics fine but omits the K8s context that matters to the +// cluster operator. This bespoke table reuses canonical shared +// primitives and surfaces the Kubelet-native columns alongside the +// usual CPU/Memory utilisation. + +const STATUS_FILTER_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Healthy' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const formatPercent = (percent?: number): JSX.Element => { + if (typeof percent !== 'number' || Number.isNaN(percent)) return ; + return {percent.toFixed(1)}%; +}; + +const formatUptime = (seconds: number | undefined): string => { + if (!seconds || seconds <= 0) return '—'; + const days = Math.floor(seconds / 86_400); + if (days > 0) return `${days}d`; + const hours = Math.floor(seconds / 3_600); + if (hours > 0) return `${hours}h`; + const mins = Math.floor(seconds / 60); + return `${mins}m`; +}; + +const formatBytes = (bytes: number | undefined): string => { + if (!bytes || bytes <= 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let value = bytes; + let unitIdx = 0; + while (value >= 1024 && unitIdx < units.length - 1) { + value /= 1024; + unitIdx += 1; + } + return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`; +}; + +const formatRoles = (roles: string[] | undefined): string => { + if (!roles || roles.length === 0) return '—'; + return roles + .map((role) => role.replace('node-role.kubernetes.io/', '')) + .join(', '); +}; + +export const KubernetesNodesTable: Component<{ + resources: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status())); + const visible = createMemo(() => filtered().length); + const total = createMemo(() => props.resources.length); + + return ( + 0} + fallback={ + + + + } + > +
+
+
+ +
+ + + {total()} nodes}> + {visible()} of {total()} nodes + + +
+ + 0} + fallback={ + + + + } + > + + + + + Node + Cluster + Roles + Kubelet + Runtime + CPU + Memory + Capacity + Uptime + + + + + {(node) => { + const meta = () => node.kubernetes; + const name = () => asTrimmedString(node.name) || node.id; + const cluster = () => + asTrimmedString(meta()?.clusterName) || + asTrimmedString(meta()?.clusterId) || + '—'; + const kubelet = () => asTrimmedString(meta()?.kubeletVersion) || '—'; + const runtime = () => asTrimmedString(meta()?.containerRuntimeVersion) || '—'; + const capacityLabel = () => { + const cores = meta()?.capacityCpuCores; + const mem = meta()?.capacityMemoryBytes; + const parts: string[] = []; + if (typeof cores === 'number' && cores > 0) parts.push(`${cores} cores`); + if (typeof mem === 'number' && mem > 0) parts.push(formatBytes(mem)); + return parts.join(' / ') || '—'; + }; + const indicator = () => getSimpleStatusIndicator(node.status); + return ( + + +
+ + + {name()} + +
+
+ {cluster()} + {formatRoles(meta()?.roles)} + + {kubelet()} + + + + {runtime()} + + + + {formatPercent(node.cpu?.current)} + + + {formatPercent(node.memory?.current)} + + + {capacityLabel()} + + + {formatUptime(node.uptime)} + +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default KubernetesNodesTable; diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index eb7df21a6..d472763d8 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -5,12 +5,12 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, - PlatformResourceTable, PlatformSectionTabs, PlatformTableEmptyState, } from '@/features/platformPage/sharedPlatformPage'; import { KubernetesClustersTable } from './KubernetesClustersTable'; import { KubernetesDeploymentsTable } from './KubernetesDeploymentsTable'; +import { KubernetesNodesTable } from './KubernetesNodesTable'; import { KUBERNETES_TAB_SPECS, buildKubernetesPageModel, @@ -90,7 +90,7 @@ export function KubernetesPageSurface() { /> -