diff --git a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx index 762ef0122..bf99321b3 100644 --- a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx +++ b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx @@ -1,5 +1,6 @@ import type { Component } from 'solid-js'; import { For, Show, createMemo, createSignal } from 'solid-js'; +import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import type { KubernetesCluster, KubernetesDeployment, @@ -129,6 +130,39 @@ export const KubernetesClusters: Component = (props) => const [viewMode, setViewMode] = createSignal('clusters'); const [statusFilter, setStatusFilter] = createSignal('all'); const [showHidden, setShowHidden] = createSignal(false); + const [namespaceFilter, setNamespaceFilter] = createSignal('all'); + + // Sorting state with persistence + type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas'; + type SortDir = 'asc' | 'desc'; + const [sortKey, setSortKey] = usePersistentSignal('k8s-sort-key', 'name'); + const [sortDirection, setSortDirection] = usePersistentSignal('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' ? ' ▲' : ' ▼') : ''; + + // Get all unique namespaces for the filter dropdown + const allNamespaces = createMemo(() => { + const namespaces = new Set(); + 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(() => { @@ -230,9 +264,13 @@ export const KubernetesClusters: Component = (props) => const filteredPods = createMemo(() => { const term = search().trim().toLowerCase(); const status = statusFilter(); + const ns = namespaceFilter(); + const key = sortKey(); + const dir = sortDirection(); - return allPods() + 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; @@ -253,14 +291,33 @@ export const KubernetesClusters: Component = (props) => .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 = search().trim().toLowerCase(); const status = statusFilter(); + const ns = namespaceFilter(); + const key = sortKey(); + const dir = sortDirection(); - return allDeployments() + 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; @@ -278,18 +335,34 @@ export const KubernetesClusters: Component = (props) => .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(), + () => search().trim() !== '' || statusFilter() !== 'all' || showHidden() || namespaceFilter() !== 'all', ); const handleReset = () => { setSearch(''); setStatusFilter('all'); setShowHidden(false); + setNamespaceFilter('all'); setViewMode('clusters'); }; @@ -358,8 +431,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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 @@ -368,8 +441,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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 @@ -378,8 +451,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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 @@ -388,8 +461,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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 @@ -404,8 +477,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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 @@ -414,8 +487,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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' }`} > @@ -425,8 +498,8 @@ export const KubernetesClusters: Component = (props) => 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' + ? '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' }`} > @@ -434,6 +507,21 @@ export const KubernetesClusters: Component = (props) => + {/* Namespace Filter - only show for pods/deployments */} + 1}> +