refactor: unify Docker container images + runtime state into single table
This commit is contained in:
parent
f07e4be94c
commit
71cf60b66c
1 changed files with 233 additions and 215 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue