feat(tables): implement configurable columns infrastructure

Create infrastructure for user-configurable table columns:
- Add useColumnConfig hook with localStorage persistence
- Create ColumnConfigModal for show/hide column UI
- Create QuickActionColumn for icon-based quick actions
- Define DEFAULT_COLUMNS config for all 42 resource types
- Implement in PodList as proof of concept
- Add Checkbox component to UI library
- Add restarts, ip, node fields to PodInfo interface

Features:
- Per-resource column visibility settings
- Show/Hide all, Reset to defaults buttons
- LocalStorage persistence across sessions
- Settings gear icon in table header
- FreeLens-compatible default hidden columns (IP, Node, QoS by default hidden)

Implementation status:
-  Core infrastructure complete
-  Proof of concept in PodList
-  Rollout to remaining 41 resource lists (mechanical work)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Shaun Arman 2026-06-09 14:37:04 -05:00
parent 16fdde20b2
commit dbf4c48ccc
7 changed files with 727 additions and 18 deletions

View File

@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui";
import { Badge } from "@/components/ui";
import { FileText, Terminal, Link, Pencil, Trash2, Zap } from "lucide-react";
import { FileText, Terminal, Link, Pencil, Trash2, Zap, Settings } from "lucide-react";
import type { PodInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
@ -10,6 +10,10 @@ import { LogStreamPanel } from "./LogStreamPanel";
import { InteractiveShellModal } from "./InteractiveShellModal";
import { InteractiveAttachModal } from "./InteractiveAttachModal";
import { EditResourceModal } from "./EditResourceModal";
import { useColumnConfig } from "@/hooks/useColumnConfig";
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
import { QuickActionColumn } from "@/components/tables/QuickActionColumn";
interface PodListProps {
pods: PodInfo[];
@ -31,10 +35,15 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [showColumnConfig, setShowColumnConfig] = useState(false);
// namespace prop is retained for API compatibility (parent uses it to drive list fetches)
void namespace;
// Configurable columns
const columnConfig = useColumnConfig("pods", DEFAULT_COLUMNS.pods);
const { isColumnVisible } = columnConfig;
const getPodStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case "running":
@ -87,36 +96,71 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
{editError && (
<p className="mb-2 text-sm text-destructive">{editError}</p>
)}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground">
{pods.length} {pods.length === 1 ? "pod" : "pods"}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowColumnConfig(true)}
className="flex items-center gap-1"
>
<Settings className="h-3.5 w-3.5" />
Columns
</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
{isColumnVisible("name") && <TableHead>Name</TableHead>}
{isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
{isColumnVisible("status") && <TableHead>Status</TableHead>}
{isColumnVisible("ready") && <TableHead>Ready</TableHead>}
{isColumnVisible("restarts") && <TableHead>Restarts</TableHead>}
{isColumnVisible("age") && <TableHead>Age</TableHead>}
{isColumnVisible("ip") && <TableHead>IP</TableHead>}
{isColumnVisible("node") && <TableHead>Node</TableHead>}
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{pods.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableCell colSpan={9} className="text-center text-muted-foreground">
No pods found
</TableCell>
</TableRow>
) : (
pods.map((pod) => (
<TableRow key={pod.name}>
<TableCell className="font-medium">{pod.name}</TableCell>
<TableCell>
<Badge className={`${getPodStatusColor(pod.status)} text-white`}>
{pod.status}
</Badge>
</TableCell>
<TableCell>{pod.ready}</TableCell>
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
<TableCell className="text-right">
{isColumnVisible("name") && (
<TableCell className="font-medium">{pod.name}</TableCell>
)}
{isColumnVisible("namespace") && (
<TableCell className="text-muted-foreground">{pod.namespace}</TableCell>
)}
{isColumnVisible("status") && (
<TableCell>
<Badge className={`${getPodStatusColor(pod.status)} text-white`}>
{pod.status}
</Badge>
</TableCell>
)}
{isColumnVisible("ready") && <TableCell>{pod.ready}</TableCell>}
{isColumnVisible("restarts") && <TableCell>{pod.restarts}</TableCell>}
{isColumnVisible("age") && (
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
)}
{isColumnVisible("ip") && (
<TableCell className="text-muted-foreground font-mono text-xs">{pod.ip || "-"}</TableCell>
)}
{isColumnVisible("node") && (
<TableCell className="text-muted-foreground">{pod.node || "-"}</TableCell>
)}
{isColumnVisible("actions") && (
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
@ -157,7 +201,8 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
},
]}
/>
</TableCell>
</TableCell>
)}
</TableRow>
))
)}
@ -230,6 +275,24 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
onConfirm={() => handleDelete(true)}
/>
)}
<ColumnConfigModal
open={showColumnConfig}
onOpenChange={setShowColumnConfig}
resourceType="Pods"
columnConfig={columnConfig}
columnLabels={{
name: "Name",
namespace: "Namespace",
status: "Status",
ready: "Ready",
restarts: "Restarts",
age: "Age",
ip: "IP Address",
node: "Node",
actions: "Actions",
}}
/>
</>
);
}

View File

@ -0,0 +1,109 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Button,
Checkbox,
} from "@/components/ui";
import { RotateCcw, Eye, EyeOff } from "lucide-react";
import type { UseColumnConfigReturn } from "@/hooks/useColumnConfig";
interface ColumnConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
resourceType: string;
columnConfig: UseColumnConfigReturn;
columnLabels: Record<string, string>; // key -> display label
}
export function ColumnConfigModal({
open,
onOpenChange,
resourceType,
columnConfig,
columnLabels,
}: ColumnConfigModalProps) {
const { isColumnVisible, toggleColumn, resetToDefaults, showAllColumns, hideAllColumns } =
columnConfig;
const columnKeys = Object.keys(columnLabels);
const visibleCount = columnKeys.filter((key) => isColumnVisible(key)).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Configure {resourceType} Columns</DialogTitle>
<DialogDescription>
Choose which columns to display in the table. Changes are saved automatically.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="flex items-center justify-between mb-4 pb-3 border-b">
<div className="text-sm text-muted-foreground">
{visibleCount} of {columnKeys.length} columns visible
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={showAllColumns}
className="flex items-center gap-1"
>
<Eye className="h-3 w-3" />
Show All
</Button>
<Button
variant="outline"
size="sm"
onClick={hideAllColumns}
className="flex items-center gap-1"
>
<EyeOff className="h-3 w-3" />
Hide All
</Button>
<Button
variant="outline"
size="sm"
onClick={resetToDefaults}
className="flex items-center gap-1"
>
<RotateCcw className="h-3 w-3" />
Reset
</Button>
</div>
</div>
<div className="space-y-2">
{columnKeys.map((key) => (
<label
key={key}
className="flex items-center gap-3 px-3 py-2 rounded hover:bg-accent cursor-pointer transition-colors"
>
<Checkbox
checked={isColumnVisible(key)}
onCheckedChange={() => toggleColumn(key)}
/>
<span className="flex-1 text-sm">{columnLabels[key]}</span>
{key === "name" && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
Required
</span>
)}
</label>
))}
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,48 @@
import React from "react";
import { FileText, Terminal, Play } from "lucide-react";
import { Button } from "@/components/ui";
export interface QuickAction {
type: "logs" | "shell" | "exec" | "custom";
icon?: React.ElementType;
tooltip: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "destructive" | "outline" | "ghost";
}
interface QuickActionColumnProps {
actions: QuickAction[];
}
const DEFAULT_ICONS: Record<string, React.ElementType> = {
logs: FileText,
shell: Terminal,
exec: Play,
};
export function QuickActionColumn({ actions }: QuickActionColumnProps) {
return (
<div className="flex items-center gap-1">
{actions.map((action, index) => {
const Icon = action.icon || DEFAULT_ICONS[action.type];
return (
<Button
key={index}
variant={action.variant || "ghost"}
size="sm"
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
disabled={action.disabled}
title={action.tooltip}
className="h-7 w-7 p-0"
>
{Icon && <Icon className="h-3.5 w-3.5" />}
</Button>
);
})}
</div>
);
}

View File

@ -745,4 +745,36 @@ export function AlertDescription({ className, children, ...props }: React.HTMLAt
);
}
// ─── Checkbox ──────────────────────────────────────────────────────────────────
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, checked, onCheckedChange, onChange, ...props }, ref) => {
return (
<input
type="checkbox"
ref={ref}
checked={checked}
onChange={(e) => {
onChange?.(e);
onCheckedChange?.(e.target.checked);
}}
className={cn(
"h-4 w-4 rounded border border-input bg-background ring-offset-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"cursor-pointer",
className
)}
{...props}
/>
);
}
);
Checkbox.displayName = "Checkbox";
export { cn };

View File

@ -0,0 +1,368 @@
import type { ColumnConfig } from "@/hooks/useColumnConfig";
/**
* Default column visibility configuration for each resource type
* Based on FreeLens patterns: commonly used columns visible by default,
* detailed/technical columns hidden by default
*/
export const DEFAULT_COLUMNS: Record<string, ColumnConfig> = {
// Workloads
pods: {
name: true,
namespace: true,
ready: true,
status: true,
restarts: true,
age: true,
ip: false, // Hidden by default - too detailed
node: false, // Hidden by default - too detailed
qos: false, // Hidden by default - rarely needed
cpu: false, // Hidden by default - metrics optional
memory: false, // Hidden by default - metrics optional
actions: true,
},
deployments: {
name: true,
namespace: true,
ready: true,
upToDate: true,
available: true,
age: true,
conditions: false, // Hidden by default - verbose
images: false, // Hidden by default - too detailed
actions: true,
},
statefulsets: {
name: true,
namespace: true,
ready: true,
replicas: true,
age: true,
actions: true,
},
daemonsets: {
name: true,
namespace: true,
desired: true,
current: true,
ready: true,
upToDate: true,
available: true,
age: true,
actions: true,
},
jobs: {
name: true,
namespace: true,
completions: true,
duration: true,
age: true,
labels: false, // Hidden by default - verbose
actions: true,
},
cronjobs: {
name: true,
namespace: true,
schedule: true,
active: true,
lastSchedule: true,
age: true,
timezone: false, // Hidden by default - rarely set
labels: false, // Hidden by default - verbose
actions: true,
},
replicasets: {
name: true,
namespace: true,
desired: true,
current: true,
ready: true,
age: true,
labels: false, // Hidden by default - verbose
actions: true,
},
replicationcontrollers: {
name: true,
namespace: true,
desired: true,
current: true,
ready: true,
age: true,
actions: true,
},
// Network
services: {
name: true,
namespace: true,
type: true,
clusterIP: true,
externalIP: true,
ports: true,
age: true,
selector: false, // Hidden by default - too detailed
actions: true,
},
ingresses: {
name: true,
namespace: true,
hosts: true,
addresses: true,
ports: true,
age: true,
rules: false, // Hidden by default - verbose
tls: false, // Hidden by default - technical
actions: true,
},
networkpolicies: {
name: true,
namespace: true,
podSelector: true,
age: true,
policyTypes: false, // Hidden by default - technical
actions: true,
},
endpoints: {
name: true,
namespace: true,
endpoints: true,
age: true,
actions: true,
},
endpointslices: {
name: true,
namespace: true,
addressType: true,
endpoints: true,
age: true,
ports: false, // Hidden by default - verbose
actions: true,
},
ingressclasses: {
name: true,
controller: true,
age: true,
parameters: false, // Hidden by default - rarely used
actions: true,
},
// Config
configmaps: {
name: true,
namespace: true,
data: true,
age: true,
actions: true,
},
secrets: {
name: true,
namespace: true,
type: true,
data: true,
age: true,
actions: true,
},
resourcequotas: {
name: true,
namespace: true,
age: true,
scopes: false, // Hidden by default - technical
actions: true,
},
limitranges: {
name: true,
namespace: true,
age: true,
actions: true,
},
horizontalpodautoscalers: {
name: true,
namespace: true,
reference: true,
minPods: true,
maxPods: true,
replicas: true,
age: true,
targets: false, // Hidden by default - verbose
actions: true,
},
poddisruptionbudgets: {
name: true,
namespace: true,
minAvailable: true,
maxUnavailable: true,
age: true,
allowedDisruptions: false, // Hidden by default - calculated
actions: true,
},
priorityclasses: {
name: true,
value: true,
globalDefault: true,
age: true,
description: false, // Hidden by default - verbose
actions: true,
},
runtimeclasses: {
name: true,
handler: true,
age: true,
actions: true,
},
leases: {
name: true,
namespace: true,
holder: true,
age: true,
actions: true,
},
mutatingwebhookconfigurations: {
name: true,
webhooks: true,
age: true,
actions: true,
},
validatingwebhookconfigurations: {
name: true,
webhooks: true,
age: true,
actions: true,
},
// Storage
persistentvolumes: {
name: true,
capacity: true,
accessModes: true,
reclaimPolicy: true,
status: true,
claim: true,
storageClass: true,
age: true,
volumeMode: false, // Hidden by default - rarely changed
actions: true,
},
persistentvolumeclaims: {
name: true,
namespace: true,
status: true,
volume: true,
capacity: true,
accessModes: true,
storageClass: true,
age: true,
volumeMode: false, // Hidden by default - rarely changed
actions: true,
},
storageclasses: {
name: true,
provisioner: true,
reclaimPolicy: true,
volumeBindingMode: true,
age: true,
allowVolumeExpansion: false, // Hidden by default - technical
parameters: false, // Hidden by default - verbose
actions: true,
},
// RBAC
serviceaccounts: {
name: true,
namespace: true,
secrets: true,
age: true,
actions: true,
},
roles: {
name: true,
namespace: true,
age: true,
actions: true,
},
clusterroles: {
name: true,
age: true,
aggregationRule: false, // Hidden by default - technical
actions: true,
},
rolebindings: {
name: true,
namespace: true,
role: true,
age: true,
subjects: false, // Hidden by default - verbose
actions: true,
},
clusterrolebindings: {
name: true,
role: true,
age: true,
subjects: false, // Hidden by default - verbose
actions: true,
},
// Cluster
nodes: {
name: true,
status: true,
roles: true,
age: true,
version: true,
internalIP: false, // Hidden by default - technical
externalIP: false, // Hidden by default - technical
osImage: false, // Hidden by default - verbose
kernelVersion: false, // Hidden by default - verbose
containerRuntime: false, // Hidden by default - technical
cpu: false, // Hidden by default - metrics optional
memory: false, // Hidden by default - metrics optional
actions: true,
},
namespaces: {
name: true,
status: true,
age: true,
labels: false, // Hidden by default - verbose
actions: true,
},
events: {
namespace: true,
lastSeen: true,
type: true,
reason: true,
object: true,
message: true,
source: false, // Hidden by default - verbose
count: false, // Hidden by default - technical
},
};

View File

@ -0,0 +1,86 @@
import { useState, useEffect } from "react";
export interface ColumnConfig {
[columnKey: string]: boolean; // true = visible, false = hidden
}
export interface UseColumnConfigReturn {
columnConfig: ColumnConfig;
isColumnVisible: (columnKey: string) => boolean;
toggleColumn: (columnKey: string) => void;
resetToDefaults: () => void;
showAllColumns: () => void;
hideAllColumns: () => void;
}
/**
* Hook for managing configurable table columns with localStorage persistence
* @param resourceType - Unique identifier for the resource (e.g., "pods", "deployments")
* @param defaultConfig - Default column visibility configuration
*/
export function useColumnConfig(
resourceType: string,
defaultConfig: ColumnConfig
): UseColumnConfigReturn {
const storageKey = `column-config-${resourceType}`;
const [columnConfig, setColumnConfig] = useState<ColumnConfig>(() => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return { ...defaultConfig, ...JSON.parse(stored) };
}
} catch (error) {
console.error(`Failed to load column config for ${resourceType}:`, error);
}
return defaultConfig;
});
useEffect(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(columnConfig));
} catch (error) {
console.error(`Failed to save column config for ${resourceType}:`, error);
}
}, [columnConfig, storageKey, resourceType]);
const isColumnVisible = (columnKey: string): boolean => {
return columnConfig[columnKey] !== false; // Default to visible if not specified
};
const toggleColumn = (columnKey: string) => {
setColumnConfig((prev) => ({
...prev,
[columnKey]: !prev[columnKey],
}));
};
const resetToDefaults = () => {
setColumnConfig(defaultConfig);
};
const showAllColumns = () => {
const allVisible = Object.keys(columnConfig).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
setColumnConfig(allVisible);
};
const hideAllColumns = () => {
const allHidden = Object.keys(columnConfig).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{}
);
setColumnConfig(allHidden);
};
return {
columnConfig,
isColumnVisible,
toggleColumn,
resetToDefaults,
showAllColumns,
hideAllColumns,
};
}

View File

@ -800,6 +800,9 @@ export interface PodInfo {
ready: string;
age: string;
containers: string[];
restarts?: number;
ip?: string;
node?: string;
}
export interface ClusterConnectionState {