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() { /> -