import { Box, FileText, Play, RotateCcw, Square, Terminal } from 'lucide-react'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend'; import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics'; import type { TerminalSession } from '../../types'; import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types'; import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals'; import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState'; import { buildDockerExecShellCommand, buildDockerExecShellCommandWindows, buildDockerLogsCommand, buildDockerLogsCommandWindows } from '../../domain/systemManager/dockerShell'; import { DockerContainerDetail } from './DockerContainerDetail'; import { DockerImageIcon } from './DockerImageIcon'; import { useStableListOrder, mergePollListByKey } from './listStable'; import { SystemPanelCollapsible, SystemPanelEmpty, SystemPanelError, SystemPanelList, SystemPanelLoading, SystemPanelMetaBar, SystemPanelRefreshButton, SystemPanelRoundButton, SystemPanelRow, SystemPanelSearch, SystemPanelSegmented, SystemPanelStatusBadge, SystemPanelToolbar, } from './SystemPanelUi'; import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog'; import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache'; import { usePolling, useStableTranslate } from '../../application/state/useSystemManager'; import { openInteractiveTerminal } from './openInteractiveTerminal'; import { showSystemManagerError } from './systemManagerToast'; type Backend = ReturnType; type ContainerFilter = 'all' | 'running' | 'stopped' | 'paused'; type PendingContainerConfirm = { containerId: string; action: 'rm' | 'kill'; }; async function buildContainerPopupIcon(image: string): Promise { const { dockerIconTileStyle, resolveDockerIconPresentation, resolveDockerImageIcon, } = await import('../../domain/systemManager/dockerImageIcons'); const iconId = resolveDockerImageIcon(image); const presentation = resolveDockerIconPresentation(iconId); const tile = dockerIconTileStyle(presentation.displayIconId); return { kind: 'image', src: presentation.iconUrl, backgroundColor: tile.background, alt: '', }; } interface DockerContainersPanelProps { sessionId: string; parentSession: TerminalSession; isVisible: boolean; warmupEnabled?: boolean; backend: Backend; listRefreshIntervalSec: number; statsRefreshIntervalSec: number; /** Target OS of the remote host — used to pick correct docker shell command. */ targetOs?: 'linux' | 'darwin' | 'win32' | 'unknown'; } const DockerContainerRow = memo(function DockerContainerRow({ container, selected, pendingAction, onSelectContainer, onShellContainer, onLogsContainer, onContainerAction, }: { container: DockerContainerInfo; selected: boolean; pendingAction: DockerContainerAction | null; onSelectContainer: (container: DockerContainerInfo) => void; onShellContainer: (container: DockerContainerInfo) => void; onLogsContainer: (container: DockerContainerInfo) => void; onContainerAction: (container: DockerContainerInfo, action: DockerContainerAction) => void; }) { const { t } = useI18n(); const shortId = container.id.slice(0, 12); const { isRunning, isPaused } = getContainerFlags(container); const actionBusy = pendingAction !== null; return ( onSelectContainer(container)} leading={} title={container.name || shortId} subtitle={container.image} trailing={(
{isRunning ? t('systemManager.docker.filter.running') : isPaused ? t('systemManager.docker.filter.paused') : t('systemManager.docker.filter.stopped')} {isRunning && ( onShellContainer(container)}> )} onLogsContainer(container)}> {isRunning && ( <> onContainerAction(container, 'restart')} > onContainerAction(container, 'stop')} > )} {isPaused && ( onContainerAction(container, 'unpause')} > )} {!isRunning && !isPaused && ( onContainerAction(container, 'start')} > )}
)} /> ); }); export const DockerContainersPanel = memo(function DockerContainersPanel({ sessionId, parentSession, isVisible, warmupEnabled = false, backend, listRefreshIntervalSec, statsRefreshIntervalSec, targetOs = 'unknown', }: DockerContainersPanelProps) { const isWindows = targetOs === 'win32'; const { t } = useI18n(); const stableT = useStableTranslate(); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [selectedId, setSelectedId] = useState(null); // Spinner feedback while a container action (stop/restart/…) runs; // cleared only after the follow-up list refresh lands. const [pendingAction, setPendingAction] = useState<{ id: string; action: DockerContainerAction } | null>(null); const [confirmAction, setConfirmAction] = useState(null); useEffect(() => { // Drop pending confirms/selection when the active terminal session changes so // a confirm opened for host A cannot run against host B. setConfirmAction(null); setPendingAction(null); setSelectedId(null); }, [sessionId]); const containersFetcher = useCallback(async () => { const result = await backend.listDockerContainers(sessionId); if (!result.success || !result.containers) { throw new Error(result.error || stableT('systemManager.errors.loadDocker')); } return result.containers; }, [backend, sessionId, stableT]); const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000; const { data: containers, error, loading, refresh } = usePolling( containersFetcher, listIntervalMs, isVisible || warmupEnabled, (prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual), { poll: isVisible, resetKey: sessionId }, ); const matched = useMemo(() => { const q = query.trim().toLowerCase(); const containerList = containers ?? []; return containerList.filter((container) => { const { isRunning, isPaused } = getContainerFlags(container); if (filter === 'running' && !isRunning) return false; if (filter === 'stopped' && (isRunning || isPaused)) return false; if (filter === 'paused' && !isPaused) return false; if (!q) return true; const shortId = container.id.slice(0, 12); return container.name.toLowerCase().includes(q) || container.image.toLowerCase().includes(q) || shortId.toLowerCase().includes(q); }); }, [containers, filter, query]); const compareContainers = useCallback( (a: DockerContainerInfo, b: DockerContainerInfo) => a.name.localeCompare(b.name), [], ); const displayList = useStableListOrder( matched, (c) => c.id, `${filter}|${query}`, compareContainers, ); const selectedContainer = useMemo( () => displayList.find((c) => c.id === selectedId) ?? null, [displayList, selectedId], ); const statContainerIds = useMemo( () => { if (!selectedContainer) return []; const { isRunning, isPaused } = getContainerFlags(selectedContainer); return isRunning || isPaused ? [selectedContainer.id] : []; }, [selectedContainer], ); const statsFetcher = useCallback(async () => { if (statContainerIds.length === 0) return []; const result = await backend.getDockerStats({ sessionId, ids: statContainerIds }); if (!result.success || !result.stats) { throw new Error(result.error || stableT('systemManager.errors.loadDockerStats')); } return result.stats; }, [backend, sessionId, stableT, statContainerIds]); const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000; const { data: stats, loading: statsLoading } = usePolling( statsFetcher, statsIntervalMs, isVisible && statContainerIds.length > 0, undefined, { poll: isVisible, resetKey: `${sessionId}:${statContainerIds.join(',')}` }, ); const statsByContainerId = useMemo(() => { const map = new Map(); for (const stat of stats ?? []) { map.set(stat.id, stat); map.set(stat.id.slice(0, 12), stat); } return map; }, [stats]); const getContainerInspectKey = useCallback((container: DockerContainerInfo) => ( `${sessionId}:${container.id}` ), [sessionId]); const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => { const result = await backend.dockerInspect({ sessionId, containerId: container.id.slice(0, 12), }); if (!result.success) { throw new Error(result.error || stableT('systemManager.errors.actionFailed')); } return result.inspect ?? null; }, [backend, sessionId, stableT]); const { records: inspectByContainerId, loadRecord: loadContainerInspect, refreshRecord: refreshContainerInspect, invalidateMatching: invalidateContainerInspectMatching, } = useAsyncRecordCache>({ items: containers ?? [], enabled: isVisible && (containers?.length ?? 0) > 0, getKey: getContainerInspectKey, fetchRecord: fetchContainerInspect, prefetchLimit: 24, prefetchDelayMs: 40, staleTimeMs: 20_000, }); const runAction = useCallback(async ( containerId: string, action: DockerContainerAction, newName?: string, options?: { skipConfirm?: boolean }, ) => { if (!options?.skipConfirm && (action === 'rm' || action === 'kill')) { setConfirmAction({ containerId, action }); return; } setPendingAction({ id: containerId, action }); try { const result = await backend.dockerAction({ sessionId, containerId, action, newName }); if (!result.success) { showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error')); return; } const affectedContainer = (containers ?? []).find((container) => ( container.id === containerId || container.id.startsWith(containerId) )); invalidateContainerInspectMatching((key) => ( key === `${sessionId}:${containerId}` || key.startsWith(`${sessionId}:${containerId}`) )); if (action === 'rm') { setSelectedId(null); } await refresh(); if (affectedContainer && action !== 'rm') { void refreshContainerInspect(affectedContainer); } } finally { setPendingAction(null); } }, [ backend, containers, invalidateContainerInspectMatching, refresh, refreshContainerInspect, sessionId, t, ]); const handleRowAction = useCallback((container: DockerContainerInfo, action: DockerContainerAction) => { void runAction(container.id.slice(0, 12), action); }, [runAction]); const selectContainer = useCallback((container: DockerContainerInfo) => { const next = selectedId === container.id ? null : container.id; setSelectedId(next); if (!next) return; void loadContainerInspect(container, { force: true, urgent: true }); }, [loadContainerInspect, selectedId]); const openShell = useCallback(async (container: DockerContainerInfo) => { const id = container.id.slice(0, 12); await writeSystemManagerDiagnostic('docker open shell clicked', { sessionId, containerId: id, containerName: container.name, image: container.image, state: container.state, }); const result = await openInteractiveTerminal( backend, parentSession, `docker: ${container.name || id}`, isWindows ? buildDockerExecShellCommandWindows(id) : buildDockerExecShellCommand(id), { icon: await buildContainerPopupIcon(container.image) }, ); if (!result.success) { await writeSystemManagerDiagnostic('docker open shell failed', { sessionId, containerId: id, containerName: container.name, error: result.error, }); showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error')); } }, [backend, parentSession, sessionId, t, isWindows]); const openLogs = useCallback(async (container: DockerContainerInfo) => { const id = container.id.slice(0, 12); await writeSystemManagerDiagnostic('docker open logs clicked', { sessionId, containerId: id, containerName: container.name, image: container.image, state: container.state, }); const result = await openInteractiveTerminal( backend, parentSession, `logs: ${container.name || id}`, isWindows ? buildDockerLogsCommandWindows(id) : buildDockerLogsCommand(id), { icon: await buildContainerPopupIcon(container.image) }, ); if (!result.success) { await writeSystemManagerDiagnostic('docker open logs failed', { sessionId, containerId: id, containerName: container.name, error: result.error, }); showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error')); } }, [backend, parentSession, sessionId, t, isWindows]); return (
void refresh()} /> )} > {t('systemManager.docker.meta', { count: String(displayList.length) })} {error && ( void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {!error && displayList.length === 0 && loading && ( )} {!error && displayList.length === 0 && !loading && ( )} {displayList.map((container) => { const selected = selectedId === container.id; const rowPending = pendingAction && pendingAction.id === container.id.slice(0, 12) ? pendingAction.action : null; const selectedInspectKey = selectedContainer ? getContainerInspectKey(selectedContainer) : null; const selectedInspectRecord = selectedInspectKey ? inspectByContainerId[selectedInspectKey] : undefined; return ( {selectedContainer && ( { setSelectedId(null); }} onRunAction={runAction} /> )} ); })} { if (!open) setConfirmAction(null); }} onConfirm={() => { const target = confirmAction; setConfirmAction(null); if (!target) return; void runAction(target.containerId, target.action, undefined, { skipConfirm: true }); }} />
); });