feat(kubernetes): Add column picker and sorting for all views

- Add sortable headers to Clusters and Nodes views
- Add ColumnPicker dropdown for Pods view (toggle Namespace, Cluster, Ready, Restarts, Image, Age columns)
- Column visibility persists to localStorage
- Sorting persists across sessions for all views
This commit is contained in:
rcourtman 2025-12-12 23:31:46 +00:00
parent 987f7a3822
commit 91f8b81547
2 changed files with 122 additions and 42 deletions

View file

@ -25,6 +25,8 @@ Edit `mock.env` (or `mock.env.local` for overrides):
| `PULSE_MOCK_LXCS_PER_NODE` | `8` | Containers per node. |
| `PULSE_MOCK_RANDOM_METRICS` | `true` | Jitter metrics. |
| `PULSE_MOCK_STOPPED_PERCENT` | `20` | % of offline guests. |
| `PULSE_MOCK_TRENDS_SEED_DURATION` | `1h` | Pre-seed backend chart history (improves demo “Trends” immediately). |
| `PULSE_MOCK_TRENDS_SAMPLE_INTERVAL` | `30s` | Backend chart sampling interval while in mock mode. |
## How it Works
* **Data**: Swaps `PULSE_DATA_DIR` to `/opt/pulse/tmp/mock-data`.

View file

@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal } from 'solid-js';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility';
import type {
KubernetesCluster,
KubernetesDeployment,
@ -11,6 +12,7 @@ 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';
@ -125,6 +127,18 @@ const formatAge = (timestamp?: number | string | null): string => {
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('');
const [viewMode, setViewMode] = createSignal<ViewMode>('clusters');
@ -132,6 +146,9 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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';
@ -207,8 +224,10 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const term = search().trim().toLowerCase();
const clusters = props.clusters ?? [];
const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
return clusters
const filtered = clusters
.filter((cluster) => showHidden() || !cluster.hidden)
.filter((cluster) => {
if (status === 'all') return true;
@ -230,15 +249,27 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
.join(' ')
.toLowerCase();
return haystack.includes(term);
})
.sort((a, b) => getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)));
});
// 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 = search().trim().toLowerCase();
const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
return allNodes()
const filtered = allNodes()
.filter(({ node }) => {
if (status === 'all') return true;
const isHealthy = node.ready && !node.unschedulable;
@ -259,6 +290,18 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
.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(() => {
@ -535,6 +578,17 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
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" />
@ -576,8 +630,8 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
<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">Cluster</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">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('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>
@ -663,9 +717,9 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
<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">Node</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cluster</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">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('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>
@ -733,18 +787,30 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
{/* Pods View */}
<Show when={viewMode() === 'pods'}>
<ScrollableTable minWidth="1100px" persistKey="kubernetes-pods-table">
<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>
<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>
<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>
<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 cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('restarts')}>Restarts{sortIndicator('restarts')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Image</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('age')}>Age{sortIndicator('age')}</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">
@ -764,37 +830,49 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
{pod.nodeName || 'unscheduled'}
</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">
{pod.namespace}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</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>
<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>
<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>
<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>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatAge(pod.createdAt)}
</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>
);
}}