From 019fdb2cfe7bf8f79571542a49bf2bebf91064cb Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Wed, 24 Jun 2026 09:39:22 -0400 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20Phase=204=20=C2=A77.3=20?= =?UTF-8?q?=E2=80=94=20split=20Docker.jsx=20monolith=20into=20per-tab=20fi?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md §7.3. Pure refactor (move + imports only, no logic change). Docker.jsx goes from 2521 → 334 lines (a thin shell: status/server/stats/tab state + layout). Extracted to components/docker/: - dockerHelpers.js — ServerContext/useServer + the pure container helpers/constants - dockerShared.jsx — IconAction, TrashIcon, ContainerResourceBars, DownloadIcon - ContainersTab.jsx — ContainersTab + ContainerInspector + Run button/modal + logs/exec modals - ComposeTab.jsx, ImagesTab.jsx, NetworksTab.jsx, VolumesTab.jsx, PruneButton.jsx The tabs were already decoupled (shared ServerContext + onStatsChange prop, no closures over the shell), so the split is mechanical. Verified: npm run build passes, 0 lint errors, and drove ALL FIVE tabs (Containers/Compose/Images/Networks/Volumes) open under Playwright with mock data — each renders correctly (tables, status/port pills, resource bars, row actions, toolbar buttons) identical to pre-split. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/docker/ComposeTab.jsx | 329 +++ .../src/components/docker/ContainersTab.jsx | 1153 +++++++++ frontend/src/components/docker/ImagesTab.jsx | 216 ++ .../src/components/docker/NetworksTab.jsx | 194 ++ .../src/components/docker/PruneButton.jsx | 49 + frontend/src/components/docker/VolumesTab.jsx | 180 ++ .../src/components/docker/dockerHelpers.js | 108 + .../src/components/docker/dockerShared.jsx | 45 + frontend/src/pages/Docker.jsx | 2192 +---------------- 9 files changed, 2289 insertions(+), 2177 deletions(-) create mode 100644 frontend/src/components/docker/ComposeTab.jsx create mode 100644 frontend/src/components/docker/ContainersTab.jsx create mode 100644 frontend/src/components/docker/ImagesTab.jsx create mode 100644 frontend/src/components/docker/NetworksTab.jsx create mode 100644 frontend/src/components/docker/PruneButton.jsx create mode 100644 frontend/src/components/docker/VolumesTab.jsx create mode 100644 frontend/src/components/docker/dockerHelpers.js create mode 100644 frontend/src/components/docker/dockerShared.jsx diff --git a/frontend/src/components/docker/ComposeTab.jsx b/frontend/src/components/docker/ComposeTab.jsx new file mode 100644 index 00000000..29e9688a --- /dev/null +++ b/frontend/src/components/docker/ComposeTab.jsx @@ -0,0 +1,329 @@ +import { useState, useEffect } from 'react'; +import api from '../../services/api'; +import { useToast } from '../../contexts/ToastContext'; +import { useConfirm } from '../../hooks/useConfirm'; +import EmptyState from '../EmptyState'; +import Modal from '@/components/Modal'; +import { Button } from '@/components/ui/button'; +import { Package, Play, Square, RotateCw, FileText } from 'lucide-react'; +import { + useServer, + unwrapRemoteData, + normalizeListResponse, +} from './dockerHelpers'; +import { IconAction, DownloadIcon } from './dockerShared'; + +// Compose Tab +const ComposeTab = ({ onStatsChange }) => { + const toast = useToast(); + const { serverId, isRemote } = useServer(); + const { confirm: confirmCompose } = useConfirm(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [logsProject, setLogsProject] = useState(null); + const [actionLoading, setActionLoading] = useState({}); + + useEffect(() => { + loadProjects(); + }, [serverId]); + + async function loadProjects() { + setLoading(true); + try { + let data; + if (isRemote) { + data = await api.getRemoteComposeProjects(serverId); + } else { + data = await api.composeList(); + } + setProjects(normalizeListResponse(data, 'projects')); + } catch (err) { + console.error('Failed to load compose projects:', err); + setProjects([]); + } finally { + setLoading(false); + } + } + + async function handleAction(project, action) { + const projectPath = project.ConfigFiles || project.config_files; + if (!projectPath) { + toast.error('Project path not found'); + return; + } + + setActionLoading(prev => ({ ...prev, [project.Name || project.name]: true })); + + try { + if (action === 'up') { + if (isRemote) { + await api.remoteComposeUp(serverId, projectPath); + } else { + await api.composeUp(projectPath, true, false); + } + toast.success('Project started'); + } else if (action === 'down') { + const downConfirmed = await confirmCompose({ title: 'Stop Compose Project', message: 'Stop this compose project? Containers will be removed.' }); + if (!downConfirmed) { + setActionLoading(prev => ({ ...prev, [project.Name || project.name]: false })); + return; + } + if (isRemote) { + await api.remoteComposeDown(serverId, projectPath); + } else { + await api.composeDown(projectPath, false, true); + } + toast.success('Project stopped'); + } else if (action === 'restart') { + if (isRemote) { + await api.remoteComposeRestart(serverId, projectPath); + } else { + await api.composeRestart(projectPath); + } + toast.success('Project restarted'); + } else if (action === 'pull') { + if (isRemote) { + await api.remoteComposePull(serverId, projectPath); + } else { + await api.composePull(projectPath); + } + toast.success('Images pulled'); + } + loadProjects(); + onStatsChange?.(); + } catch (err) { + console.error(`Failed to ${action} project:`, err); + toast.error(err.message || `Failed to ${action} project`); + } finally { + setActionLoading(prev => ({ ...prev, [project.Name || project.name]: false })); + } + } + + function getProjectStatus(project) { + const status = project.Status || project.status || ''; + if (status.includes('running')) return 'running'; + if (status.includes('exited') || status.includes('stopped')) return 'exited'; + return 'unknown'; + } + + function parseRunningCount(status) { + // Parse status like "running(3)" or "exited(2), running(1)" + const match = status.match(/running\((\d+)\)/); + return match ? parseInt(match[1], 10) : 0; + } + + if (loading) { + return
Loading compose projects...
; + } + + return ( +
+
+
+ {projects.length} project{projects.length !== 1 ? 's' : ''} found +
+ +
+ + {projects.length === 0 ? ( + docker compose up -d} + /> + ) : ( + + + + + + + + + + + {projects.map(project => { + const name = project.Name || project.name; + const status = project.Status || project.status || 'unknown'; + const configFiles = project.ConfigFiles || project.config_files || ''; + const isRunning = getProjectStatus(project) === 'running'; + const runningCount = parseRunningCount(status); + const isLoading = actionLoading[name]; + + return ( + + + + + + + ); + })} + +
ProjectStatusConfig FileActions
+ {name} + + + + {isRunning ? `Running (${runningCount})` : 'Stopped'} + +
{status}
+
+ + {configFiles} + + + setLogsProject(project)} + disabled={isLoading} + > + + + {isRunning ? ( + <> + handleAction(project, 'restart')} + disabled={isLoading} + > + + + handleAction(project, 'down')} + disabled={isLoading} + color="#EF4444" + > + + + + ) : ( + handleAction(project, 'up')} + disabled={isLoading} + color="#10B981" + > + + + )} + handleAction(project, 'pull')} + disabled={isLoading} + > + + +
+ )} + + {logsProject && ( + setLogsProject(null)} + /> + )} +
+ ); +}; + +// Compose Logs Modal +const ComposeLogsModal = ({ project, onClose }) => { + const { serverId, isRemote } = useServer(); + const [logs, setLogs] = useState(''); + const [loading, setLoading] = useState(true); + const [tail, setTail] = useState(200); + const [selectedService, setSelectedService] = useState(''); + const [services, setServices] = useState([]); + + const projectName = project.Name || project.name; + const projectPath = project.ConfigFiles || project.config_files || ''; + + useEffect(() => { + loadServices(); + loadLogs(); + }, [project, tail, selectedService]); + + async function loadServices() { + try { + let containers; + if (isRemote) { + containers = normalizeListResponse( + await api.getRemoteComposePs(serverId, projectPath), + 'containers' + ); + } else { + const result = await api.composePs(projectPath); + containers = result.containers || result || []; + } + + // Extract unique service names + const serviceNames = [...new Set( + (Array.isArray(containers) ? containers : []) + .map(c => c.Service || c.service) + .filter(Boolean) + )]; + setServices(serviceNames); + } catch (err) { + console.error('Failed to load services:', err); + } + } + + async function loadLogs() { + setLoading(true); + try { + let data; + if (isRemote) { + data = unwrapRemoteData(await api.remoteComposeLogs(serverId, projectPath, selectedService || null, tail)); + } else { + data = await api.composeLogs(projectPath, selectedService || null, tail); + } + setLogs(data.logs || 'No logs available'); + } catch (err) { + setLogs('Failed to load logs: ' + (err.message || 'Unknown error')); + } finally { + setLoading(false); + } + } + + return ( + +
+
+ + + + +
+
{loading ? 'Loading...' : logs}
+
+
+ + +
+
+ ); +}; + +export default ComposeTab; diff --git a/frontend/src/components/docker/ContainersTab.jsx b/frontend/src/components/docker/ContainersTab.jsx new file mode 100644 index 00000000..21ad18ea --- /dev/null +++ b/frontend/src/components/docker/ContainersTab.jsx @@ -0,0 +1,1153 @@ +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import api from '../../services/api'; +import { useToast } from '../../contexts/ToastContext'; +import { useConfirm } from '../../hooks/useConfirm'; +import LogToolbar from '../log-viewer/LogToolbar'; +import LogContent from '../log-viewer/LogContent'; +import EmptyState from '../EmptyState'; +import Modal from '@/components/Modal'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Box, Search, X, RefreshCw, Trash2, Play, Square, RotateCw, + Terminal as TerminalLucide, FileText, Activity, Clock3, Copy, + Database, Gauge, Package, Server as ServerIcon, ArrowUpDown, Lock, +} from 'lucide-react'; +import { + useServer, + unwrapRemoteData, + formatPorts, + normalizeListResponse, + shortId, + getContainerId, + getContainerName, + getContainerImage, + getContainerStatus, + isContainerRunning, + isProtectedContainer, + getContainerStatusLabel, + containerHue, + getContainerProjectName, +} from './dockerHelpers'; +import { ContainerResourceBars } from './dockerShared'; + +// Action Buttons +export const RunContainerButton = () => { + const [showModal, setShowModal] = useState(false); + const { isRemote } = useServer(); + return ( + <> + + {showModal && setShowModal(false)} onCreated={() => window.location.reload()} />} + + ); +}; + +// Containers Tab +const ContainersTab = ({ onStatsChange }) => { + const toast = useToast(); + const { serverId, isRemote } = useServer(); + const { confirm: confirmContainer } = useConfirm(); + const [containers, setContainers] = useState([]); + const [containerStats, setContainerStats] = useState({}); + const [loading, setLoading] = useState(true); + const [showAll, setShowAll] = useState(true); + const [selectedContainer, setSelectedContainer] = useState(null); + const [logsContainer, setLogsContainer] = useState(null); + const [execContainer, setExecContainer] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortKey, setSortKey] = useState('status'); + const [sortDirection, setSortDirection] = useState('asc'); + const statsRequestSeq = useRef(0); + + useEffect(() => { + loadContainers(); + }, [showAll, serverId]); // eslint-disable-line react-hooks/exhaustive-deps + + const fetchContainerStats = useCallback(async (container) => { + const containerId = getContainerId(container); + if (!containerId) return null; + + if (isRemote) { + const result = await api.getRemoteContainerStats(serverId, containerId); + const payload = unwrapRemoteData(result); + return payload?.stats || payload; + } + + const statsData = await api.getContainerStats(containerId); + return statsData.stats; + }, [isRemote, serverId]); + + const refreshContainerStats = useCallback(async (containerList, requestSeq = statsRequestSeq.current) => { + const runningContainers = containerList.filter(isContainerRunning); + if (runningContainers.length === 0) return; + + if (!isRemote) { + const containerIds = runningContainers.map(getContainerId).filter(Boolean); + if (containerIds.length === 0) return; + + const resolveStats = (statsMap, container) => { + const containerId = getContainerId(container); + const containerName = getContainerName(container); + return statsMap[containerId] || + statsMap[containerName] || + statsMap[`/${containerName}`] || + null; + }; + + try { + const statsData = await api.getContainersStats(containerIds); + if (requestSeq !== statsRequestSeq.current) return; + const statsMap = statsData?.stats || {}; + setContainerStats(prev => { + const next = { ...prev }; + runningContainers.forEach(container => { + next[getContainerId(container)] = resolveStats(statsMap, container); + }); + return next; + }); + } catch { + if (requestSeq !== statsRequestSeq.current) return; + setContainerStats(prev => { + const next = { ...prev }; + runningContainers.forEach(container => { + next[getContainerId(container)] = null; + }); + return next; + }); + } + return; + } + + await Promise.all(runningContainers.map(async (container) => { + const containerId = getContainerId(container); + try { + const stats = await fetchContainerStats(container); + if (requestSeq !== statsRequestSeq.current || !stats) return; + setContainerStats(prev => ({ ...prev, [containerId]: stats })); + } catch { + if (requestSeq !== statsRequestSeq.current) return; + setContainerStats(prev => ({ ...prev, [containerId]: null })); + } + })); + }, [fetchContainerStats, isRemote]); + + useEffect(() => { + if (loading || containers.length === 0) return undefined; + + const timer = window.setInterval(() => { + refreshContainerStats(containers, statsRequestSeq.current); + }, 10000); + + return () => window.clearInterval(timer); + }, [containers, loading, refreshContainerStats]); + + async function loadContainers() { + setLoading(true); + try { + let data; + if (isRemote) { + const result = await api.getRemoteContainers(serverId, showAll); + data = { containers: normalizeListResponse(result, 'containers') }; + } else { + data = await api.getContainers(showAll); + } + const containerList = data.containers || []; + const requestSeq = ++statsRequestSeq.current; + setContainers(containerList); + setContainerStats({}); + setSelectedContainer(prev => { + if (!containerList.length || !prev) return null; + return containerList.find(c => getContainerId(c) === getContainerId(prev)) || null; + }); + setLoading(false); + refreshContainerStats(containerList, requestSeq); + } catch (err) { + console.error('Failed to load containers:', err); + setLoading(false); + } + } + + async function handleAction(containerId, action) { + try { + if (action === 'start') { + if (isRemote) { + await api.startRemoteContainer(serverId, containerId); + } else { + await api.startContainer(containerId); + } + toast.success('Container started'); + } else if (action === 'stop') { + if (isRemote) { + await api.stopRemoteContainer(serverId, containerId); + } else { + await api.stopContainer(containerId); + } + toast.success('Container stopped'); + } else if (action === 'restart') { + if (isRemote) { + await api.restartRemoteContainer(serverId, containerId); + } else { + await api.restartContainer(containerId); + } + toast.success('Container restarted'); + } else if (action === 'remove') { + const removeConfirmed = await confirmContainer({ title: 'Remove Container', message: 'Remove this container?' }); + if (!removeConfirmed) return; + if (isRemote) { + await api.removeRemoteContainer(serverId, containerId, true); + } else { + await api.removeContainer(containerId, true); + } + toast.success('Container removed'); + } + loadContainers(); + onStatsChange?.(); + } catch (err) { + console.error(`Failed to ${action} container:`, err); + toast.error(err.message || `Failed to ${action} container`); + } + } + + function parseStats(stats) { + if (!stats) return { cpu: 0, memory: 0, available: false }; + const source = stats.stats || stats; + const parsePercent = (value) => { + if (typeof value === 'number') return value; + if (value === null || value === undefined) return 0; + return parseFloat(String(value).replace('%', '')) || 0; + }; + + const cpu = parsePercent(source.CPUPerc ?? source.cpu_percent ?? source.cpu?.percent); + const memory = parsePercent(source.MemPerc ?? source.memory_percent ?? source.memory?.percent); + + return { cpu, memory, available: true }; + } + + const counts = useMemo(() => { + const c = { all: containers.length, running: 0, stopped: 0 }; + containers.forEach(x => { if (isContainerRunning(x)) c.running++; else c.stopped++; }); + return c; + }, [containers]); + + const filteredContainers = useMemo(() => { + const search = searchTerm.toLowerCase(); + const filtered = containers.filter(c => { + if (statusFilter === 'running' && !isContainerRunning(c)) return false; + if (statusFilter === 'stopped' && isContainerRunning(c)) return false; + if (!search) return true; + return getContainerName(c).toLowerCase().includes(search) || + getContainerId(c).toLowerCase().includes(search) || + getContainerImage(c).toLowerCase().includes(search); + }); + + const direction = sortDirection === 'asc' ? 1 : -1; + const statusRank = (container) => isContainerRunning(container) ? 0 : 1; + const createdTime = (container) => { + const raw = container.created || container.CreatedAt || ''; + const parsed = Date.parse(raw); + return Number.isNaN(parsed) ? 0 : parsed; + }; + + return [...filtered].sort((a, b) => { + const statsA = parseStats(containerStats[getContainerId(a)]); + const statsB = parseStats(containerStats[getContainerId(b)]); + let result = 0; + + if (sortKey === 'status') { + result = statusRank(a) - statusRank(b) || + getContainerStatus(a).localeCompare(getContainerStatus(b)); + } else if (sortKey === 'name') { + result = getContainerName(a).localeCompare(getContainerName(b)); + } else if (sortKey === 'image') { + result = getContainerImage(a).localeCompare(getContainerImage(b)); + } else if (sortKey === 'cpu') { + result = statsA.cpu - statsB.cpu; + } else if (sortKey === 'memory') { + result = statsA.memory - statsB.memory; + } else if (sortKey === 'created') { + result = createdTime(a) - createdTime(b); + } + + return result * direction; + }); + }, [containers, statusFilter, searchTerm, sortKey, sortDirection, containerStats]); + + const selectedStats = selectedContainer + ? parseStats(containerStats[getContainerId(selectedContainer)]) + : { cpu: 0, memory: 0, available: false }; + + if (loading) { + return ( +
+
Loading containers...
+
+ ); + } + + return ( +
+
+
+ {[ + { id: 'all', label: 'All', count: counts.all }, + { id: 'running', label: 'Running', count: counts.running }, + { id: 'stopped', label: 'Stopped', count: counts.stopped }, + ].map(c => ( + + ))} +
+
+
+ Sort + + +
+ +
+ + setSearchTerm(e.target.value)} + /> + {searchTerm && ( + + )} +
+ +
+
+ + {filteredContainers.length === 0 ? ( + + ) : ( +
+
+
+ + + + + + + + + + + + + + {filteredContainers.map(container => { + const containerId = getContainerId(container); + const stats = parseStats(containerStats[containerId]); + const isRunning = isContainerRunning(container); + const isProtected = isProtectedContainer(container); + const ports = formatPorts(container.ports); + const isSelected = getContainerId(selectedContainer) === containerId; + return ( + setSelectedContainer(container)} + > + + + + + + + + + ); + })} + +
NameImageStatusPortsResourcesCreatedActions
+
+ + {/* hue-hashed per-container identity dot (demo .dot-ico); + dims when stopped — status itself lives in the Status pill */} + + {getContainerName(container)} + + {shortId(containerId)} +
+
+ + {getContainerImage(container)} + + + + {getContainerStatusLabel(container)} + + {getContainerStatus(container)} + +
+ {ports.slice(0, 2).map((port, i) => ( + {port} + ))} + {ports.length > 2 && +{ports.length - 2}} +
+
+ + + {container.created || container.CreatedAt || '-'} + e.stopPropagation()}> + + {isRunning && !isRemote && ( + + )} + {isProtected ? ( + + System + + ) : isRunning ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ )} + + {selectedContainer && ( + setSelectedContainer(null)} + /> + )} + + {logsContainer && ( + setLogsContainer(null)} + /> + )} + + {execContainer && ( + setExecContainer(null)} + /> + )} +
+ ); +}; + +const maskEnvValue = (entry) => { + const [key, ...rest] = String(entry).split('='); + if (!rest.length) return entry; + if (/pass|secret|token|key|credential/i.test(key)) { + return `${key}=****`; + } + return entry; +}; + +const ContainerInspector = ({ container, stats, onAction, onOpenLogs, onOpenExec, onClose }) => { + const toast = useToast(); + const { serverId, isRemote } = useServer(); + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(false); + const [activeSection, setActiveSection] = useState('overview'); + const containerId = container ? getContainerId(container) : ''; + + useEffect(() => { + let mounted = true; + let loadingTimer; + + async function loadDetails() { + if (!container) { + setDetails(null); + return; + } + + setLoading(true); + loadingTimer = window.setTimeout(() => { + if (mounted) setLoading(false); + }, 2500); + setActiveSection('overview'); + try { + let data; + if (isRemote) { + data = unwrapRemoteData(await api.getRemoteContainer(serverId, containerId)); + } else { + const result = await api.getContainer(containerId); + data = result.container || result; + } + if (mounted) setDetails(data || null); + } catch (err) { + console.error('Failed to inspect container:', err); + if (mounted) setDetails(null); + } finally { + window.clearTimeout(loadingTimer); + if (mounted) setLoading(false); + } + } + + loadDetails(); + return () => { + mounted = false; + window.clearTimeout(loadingTimer); + }; + }, [container, containerId, serverId, isRemote]); + + if (!container) { + return null; + } + + const isRunning = isContainerRunning(container); + const isProtected = isProtectedContainer(container); + const ports = formatPorts(container.ports); + const envVars = details?.Config?.Env || []; + const mounts = details?.Mounts || []; + const networks = Object.entries(details?.NetworkSettings?.Networks || {}); + const labels = details?.Config?.Labels || {}; + const restartPolicy = details?.HostConfig?.RestartPolicy?.Name || '-'; + const health = details?.State?.Health?.Status || getContainerStatusLabel(container); + const projectName = getContainerProjectName(container, details); + + async function copyContainerId() { + try { + await navigator.clipboard.writeText(containerId); + toast.success('Container ID copied'); + } catch { + toast.error('Could not copy container ID'); + } + } + + return ( + <> +
+ + + ); +}; + +const RunContainerModal = ({ onClose, onCreated }) => { + const [formData, setFormData] = useState({ + image: '', + name: '', + ports: '', + volumes: '', + env: '', + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + function handleChange(e) { + setFormData({ ...formData, [e.target.name]: e.target.value }); + } + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const data = { + image: formData.image, + name: formData.name || undefined, + ports: formData.ports ? formData.ports.split(',').map(p => p.trim()) : [], + volumes: formData.volumes ? formData.volumes.split(',').map(v => v.trim()) : [], + env: formData.env ? Object.fromEntries( + formData.env.split('\n').filter(l => l.includes('=')).map(l => { + const [key, ...rest] = l.split('='); + return [key.trim(), rest.join('=').trim()]; + }) + ) : {}, + }; + + await api.runContainer(data); + onCreated(); + onClose(); + } catch (err) { + setError(err.message || 'Failed to run container'); + } finally { + setLoading(false); + } + } + + return ( + + {error &&
{error}
} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +