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_LXCS_PER_NODE` | `8` | Containers per node. |
| `PULSE_MOCK_RANDOM_METRICS` | `true` | Jitter metrics. | | `PULSE_MOCK_RANDOM_METRICS` | `true` | Jitter metrics. |
| `PULSE_MOCK_STOPPED_PERCENT` | `20` | % of offline guests. | | `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 ## How it Works
* **Data**: Swaps `PULSE_DATA_DIR` to `/opt/pulse/tmp/mock-data`. * **Data**: Swaps `PULSE_DATA_DIR` to `/opt/pulse/tmp/mock-data`.

View file

@ -1,6 +1,7 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal } from 'solid-js'; import { For, Show, createMemo, createSignal } from 'solid-js';
import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility';
import type { import type {
KubernetesCluster, KubernetesCluster,
KubernetesDeployment, KubernetesDeployment,
@ -11,6 +12,7 @@ import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { StatusDot } from '@/components/shared/StatusDot'; import { StatusDot } from '@/components/shared/StatusDot';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { formatRelativeTime, formatBytes } from '@/utils/format'; import { formatRelativeTime, formatBytes } from '@/utils/format';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES, type StatusIndicator } from '@/utils/status'; 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); 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) => { export const KubernetesClusters: Component<KubernetesClustersProps> = (props) => {
const [search, setSearch] = createSignal(''); const [search, setSearch] = createSignal('');
const [viewMode, setViewMode] = createSignal<ViewMode>('clusters'); const [viewMode, setViewMode] = createSignal<ViewMode>('clusters');
@ -132,6 +146,9 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const [showHidden, setShowHidden] = createSignal(false); const [showHidden, setShowHidden] = createSignal(false);
const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all'); const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all');
// Column visibility for pods table
const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS);
// Sorting state with persistence // Sorting state with persistence
type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas'; type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
@ -207,8 +224,10 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
const term = search().trim().toLowerCase(); const term = search().trim().toLowerCase();
const clusters = props.clusters ?? []; const clusters = props.clusters ?? [];
const status = statusFilter(); const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
return clusters const filtered = clusters
.filter((cluster) => showHidden() || !cluster.hidden) .filter((cluster) => showHidden() || !cluster.hidden)
.filter((cluster) => { .filter((cluster) => {
if (status === 'all') return true; if (status === 'all') return true;
@ -230,15 +249,27 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
.join(' ') .join(' ')
.toLowerCase(); .toLowerCase();
return haystack.includes(term); 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 filteredNodes = createMemo(() => {
const term = search().trim().toLowerCase(); const term = search().trim().toLowerCase();
const status = statusFilter(); const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
return allNodes() const filtered = allNodes()
.filter(({ node }) => { .filter(({ node }) => {
if (status === 'all') return true; if (status === 'all') return true;
const isHealthy = node.ready && !node.unschedulable; const isHealthy = node.ready && !node.unschedulable;
@ -259,6 +290,18 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
.toLowerCase(); .toLowerCase();
return haystack.includes(term); 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 filteredPods = createMemo(() => {
@ -535,6 +578,17 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
Show hidden Show hidden
</label> </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 */} {/* Reset Button */}
<Show when={hasActiveFilters()}> <Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" /> <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"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40"> <thead class="bg-gray-50 dark:bg-gray-900/40">
<tr> <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 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">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('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">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">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">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"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40"> <thead class="bg-gray-50 dark:bg-gray-900/40">
<tr> <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 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">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('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">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('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">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">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">Memory</th>
@ -733,18 +787,30 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
{/* Pods View */} {/* Pods View */}
<Show when={viewMode() === 'pods'}> <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"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40"> <thead class="bg-gray-50 dark:bg-gray-900/40">
<tr> <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('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> <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('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('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('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> <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('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 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">Image</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('age')}>Age{sortIndicator('age')}</th> <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> </tr>
</thead> </thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <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'} {pod.nodeName || 'unscheduled'}
</div> </div>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"> <Show when={podColumns.isColumnVisible('namespace')}>
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono"> <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{pod.namespace} <span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
</span> {pod.namespace}
</td> </span>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"> </td>
{getClusterDisplayName(cluster)} </Show>
</td> <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"> <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}`}> <span class={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge().class}`}>
{statusBadge().label} {statusBadge().label}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-sm"> <Show when={podColumns.isColumnVisible('ready')}>
<span class={readyContainers() === containers().length ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}> <td class="px-4 py-3 text-sm">
{readyContainers()}/{containers().length} <span class={readyContainers() === containers().length ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
</span> {readyContainers()}/{containers().length}
</td> </span>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"> </td>
<Show when={(pod.restarts ?? 0) > 0} fallback={<span class="text-gray-400">0</span>}> </Show>
<span class="text-amber-600 dark:text-amber-400 font-medium">{pod.restarts}</span> <Show when={podColumns.isColumnVisible('restarts')}>
</Show> <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
</td> <Show when={(pod.restarts ?? 0) > 0} fallback={<span class="text-gray-400">0</span>}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"> <span class="text-amber-600 dark:text-amber-400 font-medium">{pod.restarts}</span>
<span class="font-mono text-xs truncate max-w-[150px] block" title={(pod.containers ?? [])[0]?.image}> </Show>
{getPrimaryImage(pod)} </td>
</span> </Show>
</td> <Show when={podColumns.isColumnVisible('image')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap"> <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{formatAge(pod.createdAt)} <span class="font-mono text-xs truncate max-w-[150px] block" title={(pod.containers ?? [])[0]?.image}>
</td> {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> </tr>
); );
}} }}