Watch
1
0
Fork
You've already forked RedFlag
0

refactor: unify Docker container images + runtime state into single table

This commit is contained in:
Fimeg 2026-06-11 02:28:24 -04:00
commit 71cf60b66c

View file

@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Package, AlertTriangle, Container, Layers, Activity } from 'lucide-react';
import { Package, AlertTriangle, Container, Layers } 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';
@ -69,6 +69,24 @@ const formatPorts = (ports: any[]): string => {
}).join(', ');
};
// ─── Unified row type ─────────────────────────────────────────────────────────
interface UnifiedRow {
key: string;
name: string;
image: string;
state: string;
health: string | null;
currentVersion?: string;
availableVersion?: string | null;
severity?: string;
status?: string;
stackName?: string;
ports: string;
agentName: string;
seenAt?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
const Docker: React.FC = () => {
@ -93,115 +111,142 @@ const Docker: React.FC = () => {
const images: DockerImage[] = dockerData?.images || [];
const totalCount = dockerData?.total_images || 0;
// Client-side filter by agent and stack (server handles search + status)
// Client-side filter scan containers by agent (server handles search + status)
const containers = allContainers.filter(c => {
if (values.agent && (c.agent_hostname || c.agent_name || '').toLowerCase() !== values.agent.toLowerCase()) return false;
// stack filter applied client-side via runtime containers
return true;
});
// Client-side filter images by severity
const filteredImages = values.severity
? images.filter((i: DockerImage) => i.severity === values.severity)
: images;
// Build scan-by-image lookup
const scanByImage = useMemo(() => {
const map = new Map<string, DockerContainer>();
for (const c of containers) {
map.set(c.image, c);
}
return map;
}, [containers]);
// Group containers by agent
const containersByAgent = containers.reduce((acc, c) => {
const key = c.agent_id;
if (!acc[key]) acc[key] = {
agentId: c.agent_id,
agentName: c.agent_name || c.agent_hostname || `Agent ${c.agent_id.substring(0, 8)}`,
containers: [],
};
acc[key].containers.push(c);
return acc;
}, {} as Record<string, { agentId: string; agentName: string; containers: DockerContainer[] }>);
// Build unified rows from runtime + scan data
const unifiedRows: UnifiedRow[] = useMemo(() => {
const rows: UnifiedRow[] = [];
const seenImages = new Set<string>();
const agentGroups = Object.values(containersByAgent);
// Runtime containers first (they have live state data)
for (const r of runtimeContainers) {
const scan = scanByImage.get(r.image);
seenImages.add(r.image);
rows.push({
key: r.id,
name: r.name.replace(/^\//, ''),
image: r.image,
state: r.state,
health: r.health || null,
currentVersion: scan?.current_version,
availableVersion: scan?.available_version ?? null,
severity: scan?.severity,
status: (scan as any)?.status,
stackName: r.stack_name || undefined,
ports: r.ports,
agentName: scan?.agent_name || scan?.agent_hostname || `Agent ${r.agent_id.substring(0, 8)}`,
seenAt: r.last_seen_at,
});
}
// Shared sort state for all per-agent image tables
const { sortBy: imgSortBy, sortOrder: imgSortOrder, handleSort: imgHandleSort } = useColumnSort({
defaultSortBy: 'created_at',
defaultOrder: 'desc',
});
// Scan-only containers (no runtime match) — state/health = '—'
for (const c of containers) {
if (!seenImages.has(c.image)) {
rows.push({
key: c.id,
name: `${c.image}:${c.tag}`,
image: c.image,
state: '—',
health: null,
currentVersion: c.current_version,
availableVersion: c.available_version ?? null,
severity: c.severity,
status: (c as any).status,
ports: formatPorts(c.ports),
agentName: c.agent_name || c.agent_hostname || `Agent ${c.agent_id.substring(0, 8)}`,
});
}
}
// Sort state for the runtime container table
const { sortBy: rtSortBy, sortOrder: rtSortOrder, handleSort: rtHandleSort, applySort: rtApplySort } = useColumnSort({
return rows;
}, [runtimeContainers, containers, scanByImage]);
// Client-side filtering of unified rows (text search + severity + stack + agent)
const filteredUnified = useMemo(() => {
let rows = unifiedRows;
if (searchText) {
const q = searchText.toLowerCase();
rows = rows.filter(r =>
r.name.toLowerCase().includes(q) ||
r.image.toLowerCase().includes(q) ||
r.agentName.toLowerCase().includes(q) ||
(r.stackName && r.stackName.toLowerCase().includes(q))
);
}
if (values.severity) {
rows = rows.filter(r => r.severity === values.severity);
}
if (values.stack) {
rows = rows.filter(r => r.stackName === values.stack);
}
if (values.agent) {
rows = rows.filter(r => r.agentName.toLowerCase() === values.agent.toLowerCase());
}
return rows;
}, [unifiedRows, searchText, values]);
// Sort state for unified table
const { sortBy, sortOrder, handleSort: unifiedHandleSort, applySort } = 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>[], []);
// Sort unified rows client-side
const sortedUnified = applySort(filteredUnified, (r) => {
switch (sortBy) {
case 'name': return r.name;
case 'image': return r.image;
case 'state': return r.state;
case 'health': return r.health || '';
case 'severity': return r.severity || '';
case 'stack_name': return r.stackName || '';
case 'ports': return r.ports || '';
case 'agent_name': return r.agentName;
case 'last_seen_at': return r.seenAt ? new Date(r.seenAt) : null;
default: return null;
}
});
// Column definitions — runtime containers
const runtimeColumns: Column<RuntimeDockerContainer>[] = useMemo(() => [
// Unified column definitions
const unifiedColumns: Column<UnifiedRow>[] = useMemo(() => [
{
key: 'name', label: 'Name', sortKey: 'name',
render: (r) => <span className="font-mono text-gray-900">{r.name.replace(/^\//, '')}</span>,
className: 'text-xs',
render: (r) => (
<div className="flex items-center">
<Container className="w-5 h-5 mr-3 text-blue-500 shrink-0" />
<span className="font-mono text-sm font-medium text-gray-900">{r.name}</span>
</div>
),
},
{
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',
render: (r) => <span className="text-gray-600 max-w-[14rem] truncate block text-xs" title={r.image}>{r.image}</span>,
},
{
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>
r.state !== '—'
? <span className={cn('text-[10px] border rounded px-1.5 py-0.5', getContainerStateColor(r.state))}>{r.state}</span>
: <span className="text-gray-300 text-sm"></span>
),
className: 'text-xs',
},
{
key: 'health', label: 'Health', sortKey: 'health',
@ -210,45 +255,65 @@ const Docker: React.FC = () => {
? <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: 'version', label: 'Version', sortable: false,
render: (r) => (
r.currentVersion
? <div className="text-xs">
{r.availableVersion ? (
<>
<div className="text-gray-900 font-mono">{r.currentVersion}</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-green-600 font-medium font-mono"> {r.availableVersion}</span>
{r.status && <span className={cn('badge', getStatusColor(r.status))}>{r.status}</span>}
</div>
</>
) : (
<div className="flex items-center gap-1.5">
<span className="text-gray-700 font-mono">{r.currentVersion}</span>
{r.status && <span className={cn('badge', getStatusColor(r.status))}>{r.status}</span>}
</div>
)}
</div>
: <span className="text-gray-300 text-sm"></span>
),
},
{
key: 'severity', label: 'Risk', sortKey: 'severity',
render: (r) => (
r.availableVersion && r.severity
? <span className={cn('badge', getSeverityColor(r.severity))}>{r.severity}</span>
: r.availableVersion
? <span className="badge bg-gray-100 text-gray-600">unknown</span>
: <span className="text-gray-300 text-sm"></span>
),
},
{
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>
r.stackName
? <button onClick={() => setRawQuery(`stack:${r.stackName}`)} className="hover:underline text-primary-600 text-xs">{r.stackName}</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',
key: 'agent', label: 'Agent', sortKey: 'agent_name',
render: (r) => <span className="text-sm text-gray-600">{r.agentName}</span>,
},
] 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;
}
{
key: 'seen', label: 'Seen', sortKey: 'last_seen_at',
render: (r) => (
r.seenAt
? <span className="text-gray-400 text-xs">{formatRelativeTime(r.seenAt)}</span>
: <span className="text-gray-300 text-xs"></span>
),
},
);
] as Column<UnifiedRow>[], [setRawQuery]);
// ─── Stat counts ─────────────────────────────────────────────────────────
@ -258,55 +323,55 @@ const Docker: React.FC = () => {
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<Container className="w-8 h-8 mr-3 text-blue-600" />
Docker Containers
</h1>
<p className="mt-1 text-sm text-gray-600">
Manage container image updates across all agents
</p>
</div>
<div className="text-sm text-gray-500">{totalCount} container images found</div>
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<Container className="w-8 h-8 mr-3 text-blue-600" />
Docker Containers
</h1>
<p className="mt-1 text-sm text-gray-600">
Manage container image updates across all agents
</p>
</div>
<div className="text-sm text-gray-500">{totalCount} container images found</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Images</p>
<p className="text-2xl font-bold text-gray-900">{totalCount}</p>
</div>
<Package className="h-8 w-8 text-gray-400" />
{/* Stat cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Images</p>
<p className="text-2xl font-bold text-gray-900">{totalCount}</p>
</div>
<Package className="h-8 w-8 text-gray-400" />
</div>
<FilterCountButton
count={updatesAvailable}
label="Updates Available"
isSelected={values.status === 'update-available'}
onClick={() => setFilter('status', values.status === 'update-available' ? '' : 'update-available')}
color="blue"
icon={<Container className="h-8 w-8 text-blue-400" />}
/>
<FilterCountButton
count={pendingApproval}
label="Pending Approval"
isSelected={values.status === 'update-available'}
onClick={() => setFilter('status', values.status === 'update-available' ? '' : 'update-available')}
color="orange"
icon={<AlertTriangle className="h-8 w-8 text-orange-400" />}
/>
<FilterCountButton
count={criticalUpdates}
label="Critical Updates"
isSelected={values.severity === 'critical'}
onClick={() => setFilter('severity', values.severity === 'critical' ? '' : 'critical')}
color="red"
icon={<AlertTriangle className="h-8 w-8 text-red-400" />}
/>
</div>
<FilterCountButton
count={updatesAvailable}
label="Updates Available"
isSelected={values.status === 'update-available'}
onClick={() => setFilter('status', values.status === 'update-available' ? '' : 'update-available')}
color="blue"
icon={<Container className="h-8 w-8 text-blue-400" />}
/>
<FilterCountButton
count={pendingApproval}
label="Pending Approval"
isSelected={values.status === 'update-available'}
onClick={() => setFilter('status', values.status === 'update-available' ? '' : 'update-available')}
color="orange"
icon={<AlertTriangle className="h-8 w-8 text-orange-400" />}
/>
<FilterCountButton
count={criticalUpdates}
label="Critical Updates"
isSelected={values.severity === 'critical'}
onClick={() => setFilter('severity', values.severity === 'critical' ? '' : 'critical')}
color="red"
icon={<AlertTriangle className="h-8 w-8 text-red-400" />}
/>
</div>
{/* Search + pills */}
<div className="mb-6">
@ -336,11 +401,11 @@ const Docker: React.FC = () => {
</p>
</div>
{/* Container updates table — PageState primitive */}
{/* Unified table — PageState wrapper */}
<PageState
loading={isPending}
error={error ? 'Failed to load container updates' : null}
empty={filteredImages.length === 0 && agentGroups.length === 0}
empty={sortedUnified.length === 0}
emptyTitle="No container images found"
emptyMessage={
activeCount > 0 || rawQuery
@ -348,64 +413,17 @@ const Docker: React.FC = () => {
: 'No Docker containers or images found on any agents.'
}
>
<div className="space-y-6">
{agentGroups.map((agentGroup) => (
<div key={agentGroup.agentId}>
{/* Agent header */}
<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" />
<div>
<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 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 as any).update_available).length > 0 && (
<span className="badge badge-info">Updates Available</span>
)}
</div>
</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>
<SortableTable
columns={unifiedColumns}
data={sortedUnified}
getKey={(r) => r.key}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={unifiedHandleSort}
emptyMessage="No containers match the current filters."
/>
</PageState>
{/* Runtime Container State */}
{runtimeContainers.length > 0 && (
<div className="mt-8">
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-gray-500" />
Container State
<span className="text-xs text-gray-400 font-normal">({runtimeContainers.length})</span>
</h2>
<SortableTable
columns={runtimeColumns}
data={sortedRuntime}
getKey={(r) => r.id}
sortBy={rtSortBy}
sortOrder={rtSortOrder}
onSort={rtHandleSort}
emptyMessage="No running containers match the current filters."
/>
</div>
)}
{/* Compose Stacks */}
{runtimeStacks.length > 0 && (
<div className="mt-6 mb-8">