Watch
1
0
Fork
You've already forked RedFlag
0

refactor: port Docker page to SortableTable — unify two container tables

Container images table (per-agent-group) and runtime container state table
both use SortableTable with shared sort state. ~120 lines of raw table markup
removed. Stacks grid unchanged — card layout is the right shape for that.
This commit is contained in:
Fimeg 2026-06-11 02:13:53 -04:00
commit 175d3dfd90

View file

@ -1,10 +1,12 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Package, AlertTriangle, Container, Layers, Activity } from 'lucide-react';
import { useDockerContainers, useDockerStats, useFleetRuntimeContainers, useFleetRuntimeStacks } from '@/hooks/useDocker';
import type { DockerContainer, DockerImage, RuntimeDockerContainer, RuntimeDockerStack } from '@/types';
import { formatRelativeTime, cn } from '@/lib/utils';
import { useMultimodalFilter } from '@/hooks/useMultimodalFilter';
import { SearchInput, FilterPill, FilterCountButton, PageState } from '@/components/primitives';
import { useColumnSort } from '@/hooks/useColumnSort';
import { SearchInput, FilterPill, FilterCountButton, PageState, SortableTable } from '@/components/primitives';
import type { Column } from '@/components/primitives';
// ─── Filter config ────────────────────────────────────────────────────────────
@ -116,6 +118,137 @@ const Docker: React.FC = () => {
const agentGroups = Object.values(containersByAgent);
// Shared sort state for all per-agent image tables
const { sortBy: imgSortBy, sortOrder: imgSortOrder, handleSort: imgHandleSort } = useColumnSort({
defaultSortBy: 'created_at',
defaultOrder: 'desc',
});
// Sort state for the runtime container table
const { sortBy: rtSortBy, sortOrder: rtSortOrder, handleSort: rtHandleSort, applySort: rtApplySort } = useColumnSort({
defaultSortBy: 'name',
defaultOrder: 'asc',
});
// Column definitions — container images (per-agent-group tables)
const imageColumns: Column<DockerContainer>[] = useMemo(() => [
{
key: 'image', label: 'Container Image', sortKey: 'image',
render: (c) => (
<div className="flex items-center">
<Container className="w-5 h-5 mr-3 text-blue-500 shrink-0" />
<div>
<div className="text-sm font-medium text-gray-900">{c.image}:{c.tag}</div>
{c.id !== c.image && <div className="text-xs text-gray-400 font-mono">{c.id}</div>}
</div>
</div>
),
},
{
key: 'versions', label: 'Versions', sortable: false,
render: (c) => (
<div className="text-sm">
{(c as any).update_available ? (
<>
<div className="text-gray-900 font-mono">{(c as any).current_version}</div>
<div className="text-green-600 font-medium font-mono"> {(c as any).available_version}</div>
</>
) : (
<div className="text-gray-700 font-mono">{(c as any).current_version || c.tag}</div>
)}
</div>
),
},
{
key: 'ports', label: 'Ports', sortable: false,
render: (c) => <div className="text-xs text-gray-500 font-mono">{formatPorts(c.ports)}</div>,
},
{
key: 'severity', label: 'Update Risk', sortKey: 'severity',
render: (c) => (
(c as any).update_available && (c as any).severity
? <span className={cn('badge', getSeverityColor((c as any).severity))}>{(c as any).severity}</span>
: (c as any).update_available
? <span className="badge bg-gray-100 text-gray-600">unknown</span>
: <span className="text-gray-300 text-sm"></span>
),
},
{
key: 'status', label: 'Status', sortKey: 'status',
render: (c) => <span className={cn('badge', getStatusColor((c as any).status))}>{(c as any).status}</span>,
},
{
key: 'created_at', label: 'Discovered', sortKey: 'created_at',
render: (c) => <span className="text-sm text-gray-500">{formatRelativeTime(c.created_at)}</span>,
},
] as Column<DockerContainer>[], []);
// Column definitions — runtime containers
const runtimeColumns: Column<RuntimeDockerContainer>[] = useMemo(() => [
{
key: 'name', label: 'Name', sortKey: 'name',
render: (r) => <span className="font-mono text-gray-900">{r.name.replace(/^\//, '')}</span>,
className: 'text-xs',
},
{
key: 'image', label: 'Image', sortKey: 'image',
render: (r) => <span className="text-gray-600 max-w-[14rem] truncate block" title={r.image}>{r.image}</span>,
className: 'text-xs',
},
{
key: 'state', label: 'State', sortKey: 'state',
render: (r) => (
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', getContainerStateColor(r.state))}>{r.state}</span>
),
className: 'text-xs',
},
{
key: 'health', label: 'Health', sortKey: 'health',
render: (r) => (
r.health
? <span className={cn('text-[10px] border rounded px-1.5 py-0.5', getHealthColor(r.health))}>{r.health}</span>
: <span className="text-gray-300"></span>
),
className: 'text-xs',
},
{
key: 'stack', label: 'Stack', sortKey: 'stack_name',
render: (r) => (
r.stack_name
? <button onClick={() => setRawQuery(`stack:${r.stack_name}`)} className="hover:underline text-primary-600 text-xs">{r.stack_name}</button>
: <span className="text-gray-400 text-xs"></span>
),
className: 'text-xs',
},
{
key: 'ports', label: 'Ports', sortKey: 'ports',
render: (r) => <span className="font-mono text-gray-400 text-xs">{r.ports || '—'}</span>,
className: 'text-xs',
},
{
key: 'last_seen', label: 'Seen', sortKey: 'last_seen_at',
render: (r) => <span className="text-gray-400 text-xs">{formatRelativeTime(r.last_seen_at)}</span>,
className: 'text-xs',
},
] as Column<RuntimeDockerContainer>[], [setRawQuery]);
// Sort runtime containers client-side
const sortedRuntime = rtApplySort(
runtimeContainers.filter(c => !values.stack || c.stack_name === values.stack),
(r) => {
switch (rtSortBy) {
case 'name': return r.name;
case 'image': return r.image;
case 'state': return r.state;
case 'health': return r.health || '';
case 'stack_name': return r.stack_name || '';
case 'ports': return r.ports || '';
case 'last_seen_at': return r.last_seen_at ? new Date(r.last_seen_at) : null;
default: return null;
}
},
);
// ─── Stat counts ─────────────────────────────────────────────────────────
const updatesAvailable = images.filter((i: DockerImage) => i.update_available).length;
@ -216,10 +349,9 @@ const Docker: React.FC = () => {
>
<div className="space-y-6">
{agentGroups.map((agentGroup) => (
<div key={agentGroup.agentId} className="card overflow-hidden">
<div key={agentGroup.agentId}>
{/* Agent header */}
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div className="bg-gray-50 px-6 py-4 border border-gray-200 border-b-0 rounded-t-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Container className="w-6 h-6 mr-3 text-blue-600" />
@ -227,80 +359,27 @@ const Docker: React.FC = () => {
<h3 className="text-lg font-medium text-gray-900">{agentGroup.agentName}</h3>
<p className="text-sm text-gray-500">
{agentGroup.containers.length} image{agentGroup.containers.length !== 1 ? 's' : ''}
{agentGroup.containers.filter(c => c.update_available).length > 0 &&
` · ${agentGroup.containers.filter(c => c.update_available).length} update${agentGroup.containers.filter(c => c.update_available).length !== 1 ? 's' : ''} available`}
{agentGroup.containers.filter(c => (c as any).update_available).length > 0 &&
` · ${agentGroup.containers.filter(c => (c as any).update_available).length} update${agentGroup.containers.filter(c => (c as any).update_available).length !== 1 ? 's' : ''} available`}
</p>
</div>
</div>
{agentGroup.containers.filter(c => c.update_available).length > 0 && (
{agentGroup.containers.filter(c => (c as any).update_available).length > 0 && (
<span className="badge badge-info">Updates Available</span>
)}
</div>
</div>
{/* Container table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{['Container Image', 'Versions', 'Ports', 'Update Risk', 'Status', 'Discovered'].map(h => (
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{agentGroup.containers.map((c) => (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Container className="w-5 h-5 mr-3 text-blue-500 shrink-0" />
<div>
<div className="text-sm font-medium text-gray-900">
{c.image}:{c.tag}
</div>
{c.id !== c.image && (
<div className="text-xs text-gray-400 font-mono">{c.id}</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{c.update_available ? (
<>
<div className="text-gray-900 font-mono">{c.current_version}</div>
<div className="text-green-600 font-medium font-mono"> {c.available_version}</div>
</>
) : (
<div className="text-gray-700 font-mono">{c.current_version || c.tag}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-xs text-gray-500 font-mono">
{formatPorts(c.ports)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{c.update_available && c.severity ? (
<span className={cn('badge', getSeverityColor(c.severity))}>{c.severity}</span>
) : c.update_available ? (
<span className="badge bg-gray-100 text-gray-600">unknown</span>
) : (
<span className="text-gray-300 text-sm"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn('badge', getStatusColor(c.status))}>{c.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatRelativeTime(c.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<SortableTable
columns={imageColumns}
data={agentGroup.containers}
getKey={(c) => c.id}
sortBy={imgSortBy}
sortOrder={imgSortOrder}
onSort={imgHandleSort}
emptyMessage="No containers on this agent."
className="rounded-tl-none rounded-tr-none"
/>
</div>
))}
</div>
@ -314,53 +393,15 @@ const Docker: React.FC = () => {
Container State
<span className="text-xs text-gray-400 font-normal">({runtimeContainers.length})</span>
</h2>
<div className="card overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400 text-left">
{['Name', 'Image', 'State', 'Health', 'Stack', 'Ports', 'Seen'].map(h => (
<th key={h} className="pb-2 font-normal">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{runtimeContainers
.filter(c => !values.stack || c.stack_name === values.stack)
.map((c) => (
<tr key={c.id}>
<td className="py-2 font-mono text-gray-900">{c.name.replace(/^\//, '')}</td>
<td className="py-2 text-gray-600 max-w-[14rem] truncate" title={c.image}>{c.image}</td>
<td className="py-2">
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', getContainerStateColor(c.state))}>
{c.state}
</span>
</td>
<td className="py-2">
{c.health ? (
<span className={cn('text-[10px] border rounded px-1.5 py-0.5', getHealthColor(c.health))}>
{c.health}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="py-2 text-gray-500">
{c.stack_name ? (
<button
onClick={() => setRawQuery(`stack:${c.stack_name}`)}
className="hover:underline text-primary-600"
>
{c.stack_name}
</button>
) : '—'}
</td>
<td className="py-2 font-mono text-gray-400">{c.ports || '—'}</td>
<td className="py-2 text-gray-400">{formatRelativeTime(c.last_seen_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
<SortableTable
columns={runtimeColumns}
data={sortedRuntime}
getKey={(r) => r.id}
sortBy={rtSortBy}
sortOrder={rtSortOrder}
onSort={rtHandleSort}
emptyMessage="No running containers match the current filters."
/>
</div>
)}