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:
parent
5a284f1370
commit
175d3dfd90
1 changed files with 159 additions and 118 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue