import { Component, For, Show, batch, createSignal, createMemo, createEffect, onCleanup } from 'solid-js'; import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { DockerTree, type DockerTreeHostEntry, type DockerTreeSelection, type DockerTreeServiceEntry, } from './DockerTree'; interface DockerUnifiedTableProps { hosts: DockerHost[]; searchTerm?: string; statsFilter?: { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; } const findContainerForTask = (containers: DockerContainer[], task: DockerTask) => { if (!containers.length) return undefined; const taskId = task.containerId?.toLowerCase() ?? ''; const taskName = task.containerName?.toLowerCase() ?? ''; const taskNameBase = taskName.split('.')[0] || taskName; return containers.find((container) => { const id = container.id?.toLowerCase() ?? ''; const name = container.name?.toLowerCase() ?? ''; const idMatch = !!taskId && (id === taskId || id.includes(taskId) || taskId.includes(id)); const nameMatch = !!taskName && (name === taskName || name.includes(taskName) || taskName.includes(name) || (!!taskNameBase && (name === taskNameBase || name.includes(taskNameBase)))); return idMatch || nameMatch; }); }; const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); const DEGRADED_HOST_STATUSES = new Set(['degraded', 'warning', 'maintenance', 'partial', 'initializing', 'unknown']); const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']); const ERROR_CONTAINER_STATES = new Set(['restarting', 'dead', 'removing', 'failed', 'error', 'oomkilled', 'unhealthy']); // Persistent state for expanded hosts and services const hostExpandState = new Map(); const hostExpandSignals = new Map>>(); const serviceExpandState = new Map(); const serviceExpandSignals = new Map>>(); const getTaskNodeId = (serviceKey: string, task: DockerTask, index: number) => { if (task.id) { return `${serviceKey}:task:${task.id}`; } if (task.containerId) { return `${serviceKey}:task:${task.containerId}`; } if (task.containerName) { return `${serviceKey}:task:${task.containerName}`; } if (task.slot !== undefined && task.slot !== null) { return `${serviceKey}:task:slot-${task.slot}`; } return `${serviceKey}:task:${index}`; }; // Docker Host Group Header Component (matches NodeGroupHeader style) interface DockerHostHeaderProps { host: DockerHost; colspan: number; isExpanded: boolean; onToggle: () => void; isActive?: boolean; } const DockerHostHeader: Component = (props) => { const status = () => props.host.status?.toLowerCase() ?? 'unknown'; const isOnline = () => status() === 'online'; const isOffline = () => OFFLINE_HOST_STATUSES.has(status()); const displayName = () => props.host.displayName || props.host.hostname || props.host.id; const totalContainers = () => (props.host.containers?.length || 0); const runningContainers = () => (props.host.containers?.filter((c) => c.state?.toLowerCase() === 'running').length || 0); const totalServices = () => (props.host.services?.length || 0); return ( ); }; // Service Row Component (expandable for task containers) interface ServiceRowProps { service: DockerService; hostId: string; tasks: DockerTreeServiceEntry['tasks']; containers: DockerContainer[]; isExpanded: boolean; onToggle: () => void; isSelected?: boolean; rowRef?: (row: HTMLTableRowElement | null) => void; selectedTaskId?: string | null; onTaskMount?: (taskNodeId: string, row: HTMLTableRowElement) => void; onTaskUnmount?: (taskNodeId: string) => void; } const ServiceRow: Component = (props) => { const desiredTasks = () => props.service.desiredTasks ?? 0; const runningTasks = () => props.service.runningTasks ?? 0; const isHealthy = () => desiredTasks() > 0 && runningTasks() >= desiredTasks(); const hasTasks = () => props.tasks.length > 0; onCleanup(() => { props.rowRef?.(null); }); const healthBadge = () => { if (desiredTasks() === 0) { return ( No tasks ); } if (isHealthy()) { return ( Healthy ); } return ( Degraded ({runningTasks()}/{desiredTasks()}) ); }; return ( <> row && props.rowRef?.(row)} class={`border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ props.isSelected ? 'bg-sky-50 dark:bg-sky-900/30' : '' }`} >
{props.service.image || 'Image not specified'}
{props.service.mode}
{healthBadge()} {/* Task Containers Drawer */}

Task Containers ({props.tasks.length})

{(taskEntry) => { const task = taskEntry.task; const currentState = task.currentState?.toLowerCase() || 'unknown'; const stateBadge = () => { if (currentState === 'running') { return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; } if (currentState === 'failed') { return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; } return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; }; // Find the corresponding container for this task const container = findContainerForTask(props.containers, task); const hasCpuData = () => typeof container?.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent); const hasMemData = () => typeof container?.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent); const cpuPercent = () => (hasCpuData() ? container!.cpuPercent : 0); const memPercent = () => (hasMemData() ? container!.memoryPercent : 0); const taskLabel = () => { const name = task.containerName || task.containerId?.slice(0, 12) || '—'; if (task.slot !== undefined && task.slot !== null) { return `${name}.${task.slot}`; } return name; }; onCleanup(() => { props.onTaskUnmount?.(taskEntry.nodeId); }); return ( row && props.onTaskMount?.(taskEntry.nodeId, row)} class={`hover:bg-gray-100 dark:hover:bg-gray-800/70 ${ props.selectedTaskId === taskEntry.nodeId ? 'bg-sky-50 dark:bg-sky-900/30' : '' }`} > ); }}
Task/Container Node State CPU Memory Started
{taskLabel()} {task.nodeName || task.nodeId || '—'} {task.currentState || 'Unknown'} —}> —}> {(() => { const timestamp = task.startedAt || task.createdAt; if (!timestamp) return '—'; // Handle both Unix timestamps (number) and ISO strings (from backend time.Time) const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp); return date.toLocaleString(); })()}
); }; // Container Row Component interface ContainerRowProps { container: DockerContainer; indent?: boolean; isSelected?: boolean; rowRef?: (row: HTMLTableRowElement | null) => void; } const ContainerRow: Component = (props) => { const formatPorts = () => { if (!props.container.ports || props.container.ports.length === 0) return '—'; return props.container.ports .map((p) => { if (p.publicPort) { return `${p.publicPort}:${p.privatePort}/${p.protocol}`; } return `${p.privatePort}/${p.protocol}`; }) .join(', '); }; const stateBadge = () => { const state = props.container.state?.toLowerCase() || 'unknown'; if (state === 'running') { return ( Running ); } if (state === 'exited' || state === 'stopped') { return ( Stopped ); } return ( {state} ); }; const hasCpuData = () => typeof props.container.cpuPercent === 'number' && !Number.isNaN(props.container.cpuPercent); const hasMemData = () => typeof props.container.memoryPercent === 'number' && !Number.isNaN(props.container.memoryPercent); const cpuPercent = () => (hasCpuData() ? props.container.cpuPercent! : 0); const memPercent = () => (hasMemData() ? props.container.memoryPercent! : 0); onCleanup(() => { props.rowRef?.(null); }); return ( row && props.rowRef?.(row)} class={`border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ props.isSelected ? 'bg-sky-50 dark:bg-sky-900/30' : '' }`} > {props.container.name || props.container.id?.slice(0, 12)} {props.container.image || 'Image not specified'} {stateBadge()} —}> —}> {formatPorts()} ); }; export const DockerUnifiedTable: Component = (props) => { // Track expanded state for each host const getHostExpandState = (hostId: string) => { if (!hostExpandState.has(hostId)) { hostExpandState.set(hostId, true); } if (!hostExpandSignals.has(hostId)) { hostExpandSignals.set( hostId, createSignal(hostExpandState.get(hostId) ?? true), ); } const [isExpanded, setIsExpanded] = hostExpandSignals.get(hostId)!; const setExpanded = (value: boolean) => setIsExpanded(() => { hostExpandState.set(hostId, value); return value; }); return { isExpanded, toggle: () => setIsExpanded((prev) => { const next = !prev; hostExpandState.set(hostId, next); return next; }), setExpanded, }; }; // Track expanded state for each service const getServiceExpandState = (serviceKey: string) => { if (!serviceExpandSignals.has(serviceKey)) { serviceExpandSignals.set( serviceKey, createSignal(serviceExpandState.get(serviceKey) ?? false), ); } const [isExpanded, setIsExpanded] = serviceExpandSignals.get(serviceKey)!; const setExpanded = (value: boolean) => setIsExpanded(() => { serviceExpandState.set(serviceKey, value); return value; }); return { isExpanded, toggle: () => setIsExpanded((prev) => { const next = !prev; serviceExpandState.set(serviceKey, next); return next; }), setExpanded, }; }; const normalizeContainerState = (state?: string | null, status?: string | null) => { const lowerState = state?.toLowerCase().trim(); if (lowerState) return lowerState; const lowerStatus = status?.toLowerCase().trim(); if (!lowerStatus) return ''; if (lowerStatus.startsWith('up')) return 'running'; if (lowerStatus.startsWith('exited')) return 'exited'; if (lowerStatus.startsWith('created')) return 'created'; if (lowerStatus.startsWith('paused')) return 'paused'; if (lowerStatus.includes('restarting')) return 'restarting'; if (lowerStatus.includes('unhealthy')) return 'unhealthy'; if (lowerStatus.includes('dead')) return 'dead'; if (lowerStatus.includes('removing')) return 'removing'; return lowerStatus; }; const matchesContainerStateFilter = (filterValue: string, container?: DockerContainer | null) => { if (!container) return false; const state = normalizeContainerState(container.state, container.status); if (filterValue === 'running') { return state === 'running'; } if (filterValue === 'stopped') { return STOPPED_CONTAINER_STATES.has(state); } if (filterValue === 'error') { return ERROR_CONTAINER_STATES.has(state); } return true; }; const matchesTaskStateFilter = (filterValue: string, task: DockerTask, container?: DockerContainer | null) => { if (!filterValue) return true; if (container && matchesContainerStateFilter(filterValue, container)) { return true; } const current = task.currentState?.toLowerCase() ?? ''; if (filterValue === 'running') { return current === 'running'; } if (filterValue === 'stopped') { return current === 'complete' || current === 'shutdown' || current === 'stopped'; } if (filterValue === 'error') { return current === 'failed' || current === 'error'; } return true; }; const hostMatchesFilter = (host: DockerHost) => { const filter = props.statsFilter; if (!filter || filter.type !== 'host-status') { return true; } const status = host.status?.toLowerCase() ?? 'unknown'; switch (filter.value) { case 'offline': return OFFLINE_HOST_STATUSES.has(status); case 'degraded': return DEGRADED_HOST_STATUSES.has(status); case 'online': return status === 'online'; default: return true; } }; // Parse search terms const searchTerms = createMemo(() => { const term = props.searchTerm || ''; return term .toLowerCase() .split(/[\s,]+/) .map((t) => t.trim()) .filter(Boolean); }); // Check if a container matches search terms const containerMatchesSearch = (host: DockerHost, container: DockerContainer) => { const terms = searchTerms(); if (terms.length === 0) return true; return terms.every((term) => { const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; const target = value || term; const tokens = [ container.name, container.image, container.id, container.state, container.status, host.displayName, host.hostname, ]; const hasToken = (list: (string | undefined)[]) => list .filter(Boolean) .some((entry) => entry!.toLowerCase().includes(target)); if (prefix) { switch (prefix) { case 'host': return hasToken([host.displayName, host.hostname]); case 'name': return hasToken([container.name]); case 'image': return hasToken([container.image]); case 'state': return hasToken([container.state, container.status]); case 'id': return hasToken([container.id]); default: return hasToken(tokens); } } return hasToken(tokens); }); }; // Check if a service matches search terms const serviceMatchesSearch = (host: DockerHost, service: DockerService) => { const terms = searchTerms(); if (terms.length === 0) return true; return terms.every((term) => { const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; const target = value || term; const tokens = [ service.name, service.id, service.image, host.displayName, host.hostname, ]; const hasToken = (list: (string | undefined)[]) => list .filter(Boolean) .some((entry) => entry!.toLowerCase().includes(target)); if (prefix) { switch (prefix) { case 'host': return hasToken([host.displayName, host.hostname]); case 'name': case 'service': return hasToken([service.name, service.id]); case 'image': return hasToken([service.image]); default: return hasToken(tokens); } } return hasToken(tokens); }); }; // Sort hosts alphabetically const sortedHosts = createMemo(() => { const hosts = props.hosts || []; return [...hosts].sort((a, b) => { const aName = a.displayName || a.hostname || a.id || ''; const bName = b.displayName || b.hostname || b.id || ''; return aName.localeCompare(bName); }); }); const containerFilterValue = () => props.statsFilter?.type === 'container-state' ? props.statsFilter.value : null; const visibleHosts = createMemo(() => { const results: DockerTreeHostEntry[] = []; const hosts = sortedHosts(); const filterValue = containerFilterValue(); hosts.forEach((host, index) => { if (!hostMatchesFilter(host)) { return; } const hostId = host.id || host.hostname || host.displayName || `host-${index}`; const hostContainers = host.containers || []; const hostTasks = host.tasks || []; const referencedContainers = new Set(); hostTasks.forEach((task) => { if (task.containerId) referencedContainers.add(task.containerId); if (task.containerName) referencedContainers.add(task.containerName); }); const standalone = hostContainers .filter((container) => { const id = container.id || ''; const name = container.name || ''; if (referencedContainers.has(id) || referencedContainers.has(name)) { return false; } if (!containerMatchesSearch(host, container)) { return false; } if (filterValue && !matchesContainerStateFilter(filterValue, container)) { return false; } return true; }) .map((container, idx) => ({ container, nodeId: container.id ? `${hostId}:container:${container.id}` : container.name ? `${hostId}:container:${container.name}` : `${hostId}:container:${idx}`, })); const services = (host.services || []).reduce((rows, service) => { if (!serviceMatchesSearch(host, service)) { return rows; } const filteredTasks = hostTasks.filter((task) => { const matchesService = (task.serviceId && task.serviceId === service.id) || (!task.serviceId && task.serviceName && task.serviceName === service.name); if (!matchesService) return false; if (!filterValue) return true; const container = findContainerForTask(hostContainers, task); return matchesTaskStateFilter(filterValue, task, container); }); if (filterValue && filteredTasks.length === 0) { return rows; } const identifier = service.id || service.name || 'service'; const serviceKey = `${hostId}:${identifier}`; const tasks = filteredTasks.map((task, idx) => ({ task, nodeId: getTaskNodeId(serviceKey, task, idx), })); rows.push({ key: serviceKey, service, tasks, }); return rows; }, []); if (services.length === 0 && standalone.length === 0) { return; } results.push({ host, hostId, containers: hostContainers, services, standaloneContainers: standalone, }); }); return results; }); const [selectedNode, setSelectedNode] = createSignal(null); const [isMobileTreeOpen, setIsMobileTreeOpen] = createSignal(false); const displayedHosts = createMemo(() => { const hosts = visibleHosts(); const selection = selectedNode(); if (!selection) return hosts; if (selection.type === 'host') { const match = hosts.find((host) => host.hostId === selection.hostId); return match ? [match] : hosts; } return hosts.filter((host) => host.hostId === selection.hostId); }); const hostRefs = new Map(); const serviceRefs = new Map(); const taskRefs = new Map(); const containerRefs = new Map(); const assignHostRef = (hostId: string, el: HTMLElement | null | undefined) => { if (el) { hostRefs.set(hostId, el); } else { hostRefs.delete(hostId); } }; const assignServiceRef = (serviceKey: string, el: HTMLTableRowElement | null | undefined) => { if (el) { serviceRefs.set(serviceKey, el); } else { serviceRefs.delete(serviceKey); } }; const registerTaskRef = (nodeId: string, row: HTMLTableRowElement) => { taskRefs.set(nodeId, row); }; const unregisterTaskRef = (nodeId: string) => { taskRefs.delete(nodeId); }; const assignContainerRef = (nodeId: string, el: HTMLTableRowElement | null | undefined) => { if (el) { containerRefs.set(nodeId, el); } else { containerRefs.delete(nodeId); } }; const scrollToSelection = (selection: DockerTreeSelection, smooth = true) => { if (typeof window === 'undefined') return false; const verticalScroll = ( element?: Element | null, block: ScrollLogicalPosition = 'nearest', ) => { if (!element) return false; const rect = element.getBoundingClientRect(); const topAllowance = 96; const bottomAllowance = window.innerHeight - 32; if (rect.top >= topAllowance && rect.bottom <= bottomAllowance) { return true; } element.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block, inline: 'nearest', }); return true; }; if (selection.type === 'host') { return verticalScroll(hostRefs.get(selection.hostId), 'start'); } if (selection.type === 'service') { return verticalScroll(serviceRefs.get(selection.id), 'nearest'); } if (selection.type === 'task') { return verticalScroll(taskRefs.get(selection.id), 'center'); } if (selection.type === 'container') { return verticalScroll(containerRefs.get(selection.id), 'nearest'); } return false; }; const handleTreeSelect = (selection: DockerTreeSelection) => { batch(() => { setSelectedNode(selection); const hostState = getHostExpandState(selection.hostId); hostState.setExpanded(true); if (selection.type === 'task') { const serviceState = getServiceExpandState(selection.serviceKey); serviceState.setExpanded(true); } }); setIsMobileTreeOpen(false); }; const buildSelectionFingerprint = (selection: DockerTreeSelection) => { switch (selection.type) { case 'host': return `host:${selection.hostId}`; case 'service': return `service:${selection.hostId}:${selection.id}`; case 'task': return `task:${selection.hostId}:${selection.serviceKey}:${selection.id}`; case 'container': return `container:${selection.hostId}:${selection.id}`; default: return ''; } }; let lastScrollFingerprint = ''; const attemptScrollToSelection = ( selection: DockerTreeSelection, fingerprint: string, remainingAttempts = 8, ) => { if (remainingAttempts <= 0) return; const didScroll = scrollToSelection(selection, remainingAttempts === 8); if (didScroll) { lastScrollFingerprint = fingerprint; } else if (typeof window !== 'undefined') { requestAnimationFrame(() => attemptScrollToSelection(selection, fingerprint, remainingAttempts - 1), ); } }; createEffect(() => { const selection = selectedNode(); if (!selection) return; const hosts = visibleHosts(); const hostEntry = hosts.find((entry) => entry.hostId === selection.hostId); if (!hostEntry) { setSelectedNode(null); return; } if (selection.type === 'service') { const exists = hostEntry.services.some((service) => service.key === selection.id); if (!exists) { setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); return; } } else if (selection.type === 'task') { const serviceEntry = hostEntry.services.find((service) => service.key === selection.serviceKey); if (!serviceEntry) { setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); return; } const taskExists = serviceEntry.tasks.some((task) => task.nodeId === selection.id); if (!taskExists) { setSelectedNode({ type: 'service', hostId: hostEntry.hostId, id: serviceEntry.key }); return; } } else if (selection.type === 'container') { const exists = hostEntry.standaloneContainers.some((container) => container.nodeId === selection.id); if (!exists) { setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); return; } } const fingerprint = buildSelectionFingerprint(selection); if (!fingerprint || fingerprint === lastScrollFingerprint) return; attemptScrollToSelection(selection, fingerprint); }); const hasVisibleHosts = createMemo(() => visibleHosts().length > 0); return (
setIsMobileTreeOpen(false)} />
{(entry) => { const hostState = getHostExpandState(entry.hostId); const currentSelection = () => selectedNode(); const isHostActive = () => currentSelection()?.hostId === entry.hostId; const displayedServices = createMemo(() => { const selection = currentSelection(); if (!selection || selection.type === 'host') return entry.services; if (selection.type === 'service') { return entry.services.filter((service) => service.key === selection.id); } if (selection.type === 'task') { return entry.services.filter((service) => service.key === selection.serviceKey); } return []; }); const displayedStandaloneContainers = createMemo(() => { const selection = currentSelection(); if (!selection || selection.type === 'host') return entry.standaloneContainers; if (selection.type === 'container') { return entry.standaloneContainers.filter((container) => container.nodeId === selection.id); } return []; }); const shouldShowServices = createMemo(() => displayedServices().length > 0); const shouldShowContainers = createMemo(() => displayedStandaloneContainers().length > 0); return (
assignHostRef(entry.hostId, el)} class="space-y-4 scroll-mt-28 min-w-0" >
{(serviceEntry) => { const serviceState = getServiceExpandState(serviceEntry.key); const serviceSelected = () => { const selection = selectedNode(); if (!selection) return false; if (selection.type === 'service') { return selection.id === serviceEntry.key; } if (selection.type === 'task') { return selection.serviceKey === serviceEntry.key; } return false; }; const selectedTaskId = () => { const selection = selectedNode(); if (selection?.type === 'task' && selection.serviceKey === serviceEntry.key) { return selection.id; } return null; }; onCleanup(() => { serviceRefs.delete(serviceEntry.key); }); return ( assignServiceRef(serviceEntry.key, row)} selectedTaskId={selectedTaskId()} onTaskMount={registerTaskRef} onTaskUnmount={unregisterTaskRef} /> ); }}
Service Image Status
{(containerEntry) => { const isContainerSelected = () => selectedNode()?.type === 'container' && selectedNode()?.id === containerEntry.nodeId; return ( assignContainerRef(containerEntry.nodeId, row)} /> ); }}
Container Image Status CPU Memory Ports
); }}
); };