Pulse/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx
rcourtman d5638c7c14 perf: debounce search input in Kubernetes clusters view
Prevents UI jank during rapid typing by using a 200ms debounced search term.
2025-12-13 21:28:19 +00:00

986 lines
51 KiB
TypeScript

import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal, createEffect } from 'solid-js';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility';
import type {
KubernetesCluster,
KubernetesDeployment,
KubernetesNode,
KubernetesPod,
} from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { StatusDot } from '@/components/shared/StatusDot';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { formatRelativeTime, formatBytes } from '@/utils/format';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES, type StatusIndicator } from '@/utils/status';
interface KubernetesClustersProps {
clusters: KubernetesCluster[];
}
type ViewMode = 'clusters' | 'nodes' | 'pods' | 'deployments';
type StatusFilter = 'all' | 'healthy' | 'unhealthy';
const normalize = (value?: string | null): string => (value || '').trim().toLowerCase();
const getStatusIndicator = (status: string | undefined | null): StatusIndicator => {
const normalized = normalize(status);
if (!normalized) return { variant: 'muted', label: 'Unknown' };
if (OFFLINE_HEALTH_STATUSES.has(normalized)) return { variant: 'danger', label: 'Offline' };
if (DEGRADED_HEALTH_STATUSES.has(normalized)) return { variant: 'warning', label: 'Degraded' };
if (normalized === 'online') return { variant: 'success', label: 'Online' };
return { variant: 'muted', label: status ?? 'Unknown' };
};
const isPodHealthy = (pod: KubernetesPod): boolean => {
const phase = normalize(pod.phase);
if (!phase) return false;
if (phase !== 'running') return false;
const containers = pod.containers ?? [];
if (containers.length === 0) return true;
return containers.every((container) => {
if (!container.ready) return false;
const state = normalize(container.state);
if (!state) return true;
return state === 'running';
});
};
const isDeploymentHealthy = (d: KubernetesDeployment): boolean => {
const desired = d.desiredReplicas ?? 0;
if (desired <= 0) return true;
const available = d.availableReplicas ?? 0;
const ready = d.readyReplicas ?? 0;
const updated = d.updatedReplicas ?? 0;
return available >= desired && ready >= desired && updated >= desired;
};
const getClusterDisplayName = (cluster: KubernetesCluster): string => {
return cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id;
};
const summarizeNodes = (nodes: KubernetesNode[] | undefined) => {
const list = nodes ?? [];
const notReady = list.filter((n) => !n.ready).length;
const unschedulable = list.filter((n) => !!n.unschedulable).length;
return { total: list.length, notReady, unschedulable };
};
const summarizePods = (pods: KubernetesPod[] | undefined) => {
const list = pods ?? [];
const unhealthy = list.filter((p) => !isPodHealthy(p)).length;
return { total: list.length, unhealthy };
};
const summarizeDeployments = (deployments: KubernetesDeployment[] | undefined) => {
const list = deployments ?? [];
const unhealthy = list.filter((d) => !isDeploymentHealthy(d)).length;
return { total: list.length, unhealthy };
};
const getPodStatusBadge = (pod: KubernetesPod) => {
if (isPodHealthy(pod)) {
return { class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', label: 'Running' };
}
const phase = normalize(pod.phase);
if (phase === 'pending') {
return { class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', label: 'Pending' };
}
if (phase === 'failed') {
return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'Failed' };
}
if (phase === 'succeeded') {
return { class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', label: 'Completed' };
}
// Check for CrashLoopBackOff or other container issues
const containers = pod.containers ?? [];
const crashingContainer = containers.find((c) => c.reason?.toLowerCase().includes('crash'));
if (crashingContainer) {
return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'CrashLoop' };
}
const waitingContainer = containers.find((c) => normalize(c.state) === 'waiting');
if (waitingContainer) {
return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: waitingContainer.reason || 'Waiting' };
}
return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: pod.phase || 'Unknown' };
};
// Get primary container image (first container)
const getPrimaryImage = (pod: KubernetesPod): string => {
const containers = pod.containers ?? [];
if (containers.length === 0) return '—';
const image = containers[0].image ?? '';
// Truncate long image names, show just the image:tag part
const parts = image.split('/');
return parts[parts.length - 1] || image || '—';
};
// Format age from timestamp
const formatAge = (timestamp?: number | string | null): string => {
if (!timestamp) return '—';
const ts = typeof timestamp === 'string' ? Date.parse(timestamp) : timestamp;
if (isNaN(ts)) return '—';
return formatRelativeTime(ts);
};
// Column definitions for the pods table
const POD_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Pod', priority: 'essential', toggleable: false },
{ id: 'namespace', label: 'Namespace', priority: 'essential', toggleable: true },
{ id: 'cluster', label: 'Cluster', priority: 'primary', toggleable: true },
{ id: 'status', label: 'Status', priority: 'essential', toggleable: false },
{ id: 'ready', label: 'Ready', priority: 'primary', toggleable: true },
{ id: 'restarts', label: 'Restarts', priority: 'primary', toggleable: true },
{ id: 'image', label: 'Image', priority: 'secondary', toggleable: true },
{ id: 'age', label: 'Age', priority: 'primary', toggleable: true },
];
export const KubernetesClusters: Component<KubernetesClustersProps> = (props) => {
const [search, setSearch] = createSignal('');
// PERFORMANCE: Debounce search term to prevent jank during rapid typing
const debouncedSearch = useDebouncedValue(() => search(), 200);
const [viewMode, setViewMode] = createSignal<ViewMode>('clusters');
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
const [showHidden, setShowHidden] = createSignal(false);
const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all');
// Column visibility for pods table
const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS);
// Sorting state with persistence
type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas';
type SortDir = 'asc' | 'desc';
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('k8s-sort-key', 'name');
const [sortDirection, setSortDirection] = usePersistentSignal<SortDir>('k8s-sort-dir', 'asc');
const toggleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection('asc');
}
};
const sortIndicator = (key: SortKey) => sortKey() === key ? (sortDirection() === 'asc' ? ' ▲' : ' ▼') : '';
// Search input ref for keyboard focus
let searchInputRef: HTMLInputElement | undefined;
// Global keyboard handler - focus search on typing
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape clears search
if (e.key === 'Escape' && searchInputRef) {
setSearch('');
searchInputRef.blur();
return;
}
// Focus search on printable character (when not in input field)
if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
searchInputRef?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Get all unique namespaces for the filter dropdown
const allNamespaces = createMemo(() => {
const namespaces = new Set<string>();
for (const cluster of props.clusters ?? []) {
if (!showHidden() && cluster.hidden) continue;
for (const pod of cluster.pods ?? []) {
if (pod.namespace) namespaces.add(pod.namespace);
}
for (const dep of cluster.deployments ?? []) {
if (dep.namespace) namespaces.add(dep.namespace);
}
}
return Array.from(namespaces).sort();
});
// Get all nodes flattened across clusters
const allNodes = createMemo(() => {
const clusters = props.clusters ?? [];
const nodes: Array<{ cluster: KubernetesCluster; node: KubernetesNode }> = [];
for (const cluster of clusters) {
if (!showHidden() && cluster.hidden) continue;
for (const node of cluster.nodes ?? []) {
nodes.push({ cluster, node });
}
}
return nodes;
});
// Get all pods flattened across clusters
const allPods = createMemo(() => {
const clusters = props.clusters ?? [];
const pods: Array<{ cluster: KubernetesCluster; pod: KubernetesPod }> = [];
for (const cluster of clusters) {
if (!showHidden() && cluster.hidden) continue;
for (const pod of cluster.pods ?? []) {
pods.push({ cluster, pod });
}
}
return pods;
});
// Get all deployments flattened across clusters
const allDeployments = createMemo(() => {
const clusters = props.clusters ?? [];
const deps: Array<{ cluster: KubernetesCluster; deployment: KubernetesDeployment }> = [];
for (const cluster of clusters) {
if (!showHidden() && cluster.hidden) continue;
for (const dep of cluster.deployments ?? []) {
deps.push({ cluster, deployment: dep });
}
}
return deps;
});
const visibleClusters = createMemo(() => {
// PERFORMANCE: Use debounced search term
const term = debouncedSearch().trim().toLowerCase();
const clusters = props.clusters ?? [];
const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = clusters
.filter((cluster) => showHidden() || !cluster.hidden)
.filter((cluster) => {
if (status === 'all') return true;
const clusterStatus = normalize(cluster.status);
const isHealthy = clusterStatus === 'online';
if (status === 'healthy') return isHealthy;
if (status === 'unhealthy') return !isHealthy;
return true;
})
.filter((cluster) => {
if (!term) return true;
const haystack = [
getClusterDisplayName(cluster),
cluster.id,
cluster.server ?? '',
cluster.context ?? '',
cluster.version ?? '',
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort clusters
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)); break;
case 'status': cmp = (normalize(a.status) === 'online' ? 0 : 1) - (normalize(b.status) === 'online' ? 0 : 1); break;
default: cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b));
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredNodes = createMemo(() => {
const term = debouncedSearch().trim().toLowerCase();
const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = allNodes()
.filter(({ node }) => {
if (status === 'all') return true;
const isHealthy = node.ready && !node.unschedulable;
if (status === 'healthy') return isHealthy;
if (status === 'unhealthy') return !isHealthy;
return true;
})
.filter(({ cluster, node }) => {
if (!term) return true;
const haystack = [
node.name,
getClusterDisplayName(cluster),
node.kubeletVersion ?? '',
node.osImage ?? '',
...(node.roles ?? []),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort nodes
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.node.name ?? '').localeCompare(b.node.name ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'status': cmp = (a.node.ready ? 0 : 1) - (b.node.ready ? 0 : 1); break;
default: cmp = (a.node.name ?? '').localeCompare(b.node.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredPods = createMemo(() => {
const term = debouncedSearch().trim().toLowerCase();
const status = statusFilter();
const ns = namespaceFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = allPods()
.filter(({ pod }) => {
if (ns !== 'all' && pod.namespace !== ns) return false;
if (status === 'all') return true;
const healthy = isPodHealthy(pod);
if (status === 'healthy') return healthy;
if (status === 'unhealthy') return !healthy;
return true;
})
.filter(({ cluster, pod }) => {
if (!term) return true;
const haystack = [
pod.name,
pod.namespace,
pod.nodeName ?? '',
pod.phase ?? '',
getClusterDisplayName(cluster),
...(pod.containers ?? []).map(c => c.image ?? ''),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); break;
case 'namespace': cmp = (a.pod.namespace ?? '').localeCompare(b.pod.namespace ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'restarts': cmp = (a.pod.restarts ?? 0) - (b.pod.restarts ?? 0); break;
case 'age': cmp = (a.pod.createdAt ?? 0) - (b.pod.createdAt ?? 0); break;
case 'status': cmp = (isPodHealthy(a.pod) ? 0 : 1) - (isPodHealthy(b.pod) ? 0 : 1); break;
default: cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredDeployments = createMemo(() => {
const term = debouncedSearch().trim().toLowerCase();
const status = statusFilter();
const ns = namespaceFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = allDeployments()
.filter(({ deployment }) => {
if (ns !== 'all' && deployment.namespace !== ns) return false;
if (status === 'all') return true;
const healthy = isDeploymentHealthy(deployment);
if (status === 'healthy') return healthy;
if (status === 'unhealthy') return !healthy;
return true;
})
.filter(({ cluster, deployment }) => {
if (!term) return true;
const haystack = [
deployment.name,
deployment.namespace,
getClusterDisplayName(cluster),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); break;
case 'namespace': cmp = (a.deployment.namespace ?? '').localeCompare(b.deployment.namespace ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'replicas': cmp = (a.deployment.desiredReplicas ?? 0) - (b.deployment.desiredReplicas ?? 0); break;
case 'ready': cmp = (a.deployment.readyReplicas ?? 0) - (b.deployment.readyReplicas ?? 0); break;
case 'status': cmp = (isDeploymentHealthy(a.deployment) ? 0 : 1) - (isDeploymentHealthy(b.deployment) ? 0 : 1); break;
default: cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const isEmpty = createMemo(() => (props.clusters?.length ?? 0) === 0);
const hasActiveFilters = createMemo(
() => search().trim() !== '' || statusFilter() !== 'all' || showHidden() || namespaceFilter() !== 'all',
);
const handleReset = () => {
setSearch('');
setStatusFilter('all');
setShowHidden(false);
setNamespaceFilter('all');
setViewMode('clusters');
};
return (
<div class="space-y-4">
{/* Filter Bar */}
<Card padding="sm">
<div class="flex flex-col gap-3">
{/* Search - full width on its own row */}
<div class="relative">
<input
ref={searchInputRef}
type="text"
placeholder="Search clusters, nodes, pods..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearch('');
e.currentTarget.blur();
}
}}
class="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={search()}>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => setSearch('')}
aria-label="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
{/* Filters - second row */}
<div class="flex flex-wrap items-center gap-2">
{/* View Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setViewMode('clusters')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'clusters'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Clusters
</button>
<button
type="button"
onClick={() => setViewMode('nodes')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'nodes'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Nodes
</button>
<button
type="button"
onClick={() => setViewMode('pods')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'pods'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Pods
</button>
<button
type="button"
onClick={() => setViewMode('deployments')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'deployments'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Deployments
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => setStatusFilter(statusFilter() === 'healthy' ? 'all' : 'healthy')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'healthy'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm ring-1 ring-green-200 dark:ring-green-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
<span class={`w-2 h-2 rounded-full ${statusFilter() === 'healthy' ? 'bg-green-500' : 'bg-green-400/60'}`} />
Healthy
</button>
<button
type="button"
onClick={() => setStatusFilter(statusFilter() === 'unhealthy' ? 'all' : 'unhealthy')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'unhealthy'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm ring-1 ring-amber-200 dark:ring-amber-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
<span class={`w-2 h-2 rounded-full ${statusFilter() === 'unhealthy' ? 'bg-amber-500' : 'bg-amber-400/60'}`} />
Unhealthy
</button>
</div>
{/* Namespace Filter - only show for pods/deployments */}
<Show when={(viewMode() === 'pods' || viewMode() === 'deployments') && allNamespaces().length > 1}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<select
value={namespaceFilter()}
onChange={(e) => setNamespaceFilter(e.currentTarget.value)}
class="px-2.5 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<option value="all">All namespaces</option>
<For each={allNamespaces()}>
{(ns) => <option value={ns}>{ns}</option>}
</For>
</select>
</Show>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
{/* Show Hidden Toggle */}
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 cursor-pointer select-none">
<input
type="checkbox"
checked={showHidden()}
onChange={(e) => setShowHidden(e.currentTarget.checked)}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500/20"
/>
Show hidden
</label>
{/* Column Picker - only show for pods view */}
<Show when={viewMode() === 'pods'}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<ColumnPicker
columns={podColumns.availableToggles()}
isHidden={podColumns.isHiddenByUser}
onToggle={podColumns.toggle}
onReset={podColumns.resetToDefaults}
/>
</Show>
{/* Reset Button */}
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<button
type="button"
onClick={handleReset}
class="flex items-center justify-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
title="Reset filters"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="hidden sm:inline">Reset</span>
</button>
</Show>
</div>
</div>
</Card>
{/* Main Table */}
<Card padding="none" class="overflow-hidden">
<Show
when={!isEmpty()}
fallback={
<div class="p-10">
<EmptyState
title="No Kubernetes clusters reporting yet"
description="Enable Kubernetes monitoring on a unified agent to start reporting cluster health."
/>
</div>
}
>
{/* Clusters View */}
<Show when={viewMode() === 'clusters'}>
<ScrollableTable minWidth="900px" persistKey="kubernetes-clusters-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Cluster{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nodes</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pods</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deployments</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Version</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Seen</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={visibleClusters()} fallback={
<tr><td colSpan={7} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No clusters match the current filters.</td></tr>
}>
{(cluster) => {
const indicator = () => getStatusIndicator(cluster.status);
const nodes = () => summarizeNodes(cluster.nodes);
const pods = () => summarizePods(cluster.pods);
const deployments = () => summarizeDeployments(cluster.deployments);
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="flex items-center gap-2">
<span class="font-medium">{getClusterDisplayName(cluster)}</span>
<Show when={cluster.pendingUninstall}>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
Pending uninstall
</span>
</Show>
<Show when={cluster.hidden}>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
Hidden
</span>
</Show>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-xs font-mono">
{cluster.server || '—'}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<div class="flex items-center gap-2">
<StatusDot variant={indicator().variant} size="sm" />
<span>{indicator().label}</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class={nodes().notReady > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{nodes().total - nodes().notReady}</span>
<span class="text-gray-400">/{nodes().total}</span>
<Show when={nodes().notReady > 0}>
<span class="ml-1 text-xs text-gray-400">ready</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class={pods().unhealthy > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{pods().total - pods().unhealthy}</span>
<span class="text-gray-400">/{pods().total}</span>
<Show when={pods().unhealthy > 0}>
<span class="ml-1 text-xs text-gray-400">healthy</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class={deployments().unhealthy > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{deployments().total - deployments().unhealthy}</span>
<span class="text-gray-400">/{deployments().total}</span>
<Show when={deployments().unhealthy > 0}>
<span class="ml-1 text-xs text-gray-400">ok</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{cluster.version || '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
{/* Nodes View */}
<Show when={viewMode() === 'nodes'}>
<ScrollableTable minWidth="1000px" persistKey="kubernetes-nodes-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Node{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Roles</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CPU</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Memory</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pods</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Version</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredNodes()} fallback={
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No nodes match the current filters.</td></tr>
}>
{({ cluster, node }) => {
const isHealthy = () => node.ready && !node.unschedulable;
const roles = () => (node.roles ?? []).join(', ') || 'worker';
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="font-medium">{node.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs">
{node.osImage || '—'}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</td>
<td class="px-4 py-3 text-sm">
<Show when={isHealthy()} fallback={
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500" />
{!node.ready ? 'NotReady' : 'Unschedulable'}
</span>
}>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<span class="w-1.5 h-1.5 rounded-full bg-green-500" />
Ready
</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">
{roles()}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.allocatableCpuCores ?? node.capacityCpuCores ?? '—'} cores
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.allocatableMemoryBytes ? formatBytes(node.allocatableMemoryBytes) : node.capacityMemoryBytes ? formatBytes(node.capacityMemoryBytes) : '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.allocatablePods ?? node.capacityPods ?? '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.kubeletVersion || '—'}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
{/* Pods View */}
<Show when={viewMode() === 'pods'}>
<ScrollableTable minWidth="800px" persistKey="kubernetes-pods-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Pod{sortIndicator('name')}</th>
<Show when={podColumns.isColumnVisible('namespace')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('namespace')}>Namespace{sortIndicator('namespace')}</th>
</Show>
<Show when={podColumns.isColumnVisible('cluster')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
</Show>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<Show when={podColumns.isColumnVisible('ready')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('ready')}>Ready{sortIndicator('ready')}</th>
</Show>
<Show when={podColumns.isColumnVisible('restarts')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('restarts')}>Restarts{sortIndicator('restarts')}</th>
</Show>
<Show when={podColumns.isColumnVisible('image')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Image</th>
</Show>
<Show when={podColumns.isColumnVisible('age')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('age')}>Age{sortIndicator('age')}</th>
</Show>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredPods()} fallback={
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No pods match the current filters.</td></tr>
}>
{({ cluster, pod }) => {
const statusBadge = () => getPodStatusBadge(pod);
const containers = () => pod.containers ?? [];
const readyContainers = () => containers().filter(c => c.ready).length;
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="font-medium truncate max-w-[200px]" title={pod.name}>{pod.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{pod.nodeName || 'unscheduled'}
</div>
</td>
<Show when={podColumns.isColumnVisible('namespace')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
{pod.namespace}
</span>
</td>
</Show>
<Show when={podColumns.isColumnVisible('cluster')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</td>
</Show>
<td class="px-4 py-3 text-sm">
<span class={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge().class}`}>
{statusBadge().label}
</span>
</td>
<Show when={podColumns.isColumnVisible('ready')}>
<td class="px-4 py-3 text-sm">
<span class={readyContainers() === containers().length ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
{readyContainers()}/{containers().length}
</span>
</td>
</Show>
<Show when={podColumns.isColumnVisible('restarts')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<Show when={(pod.restarts ?? 0) > 0} fallback={<span class="text-gray-400">0</span>}>
<span class="text-amber-600 dark:text-amber-400 font-medium">{pod.restarts}</span>
</Show>
</td>
</Show>
<Show when={podColumns.isColumnVisible('image')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="font-mono text-xs truncate max-w-[150px] block" title={(pod.containers ?? [])[0]?.image}>
{getPrimaryImage(pod)}
</span>
</td>
</Show>
<Show when={podColumns.isColumnVisible('age')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatAge(pod.createdAt)}
</td>
</Show>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
{/* Deployments View */}
<Show when={viewMode() === 'deployments'}>
<ScrollableTable minWidth="800px" persistKey="kubernetes-deployments-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Deployment{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('namespace')}>Namespace{sortIndicator('namespace')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('replicas')}>Replicas{sortIndicator('replicas')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('ready')}>Ready{sortIndicator('ready')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Up-to-date</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredDeployments()} fallback={
<tr><td colSpan={7} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No deployments match the current filters.</td></tr>
}>
{({ cluster, deployment }) => {
const healthy = () => isDeploymentHealthy(deployment);
const desired = () => deployment.desiredReplicas ?? 0;
const ready = () => deployment.readyReplicas ?? 0;
const updated = () => deployment.updatedReplicas ?? 0;
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="font-medium">{deployment.name}</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
{deployment.namespace}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</td>
<td class="px-4 py-3 text-sm">
<Show when={healthy()} fallback={
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500" />
Progressing
</span>
}>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<span class="w-1.5 h-1.5 rounded-full bg-green-500" />
Available
</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-medium">
{desired()}
</td>
<td class="px-4 py-3 text-sm">
<span class={ready() >= desired() ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
{ready()}/{desired()}
</span>
</td>
<td class="px-4 py-3 text-sm">
<span class={updated() >= desired() ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
{updated()}/{desired()}
</span>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
</Show>
</Card>
</div>
);
};