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}
+ />
+ ) : (
+
+
+
+ | Project |
+ Status |
+ Config File |
+ Actions |
+
+
+
+ {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 (
+
+ |
+ {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 ? (
+
+ ) : (
+
+
+
+
+
+
+ | Name |
+ Image |
+ Status |
+ Ports |
+ Resources |
+ Created |
+ Actions |
+
+
+
+ {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)}
+ >
+ |
+
+
+ {/* 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}
}
+
+
+
+ );
+};
+
+const ContainerLogsModal = ({ container, onClose }) => {
+ const { serverId, isRemote } = useServer();
+ const containerId = getContainerId(container);
+ const [logs, setLogs] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [tail, setTail] = useState(200);
+ const [searchPattern, setSearchPattern] = useState('');
+ const [appliedSearch, setAppliedSearch] = useState('');
+ const [autoRefresh, setAutoRefresh] = useState(false);
+ const [showLineNumbers, setShowLineNumbers] = useState(true);
+ const [wrapLines, setWrapLines] = useState(true);
+ const contentRef = useRef(null);
+ const intervalRef = useRef(null);
+
+ useEffect(() => {
+ loadLogs();
+ }, [container, tail]); // eslint-disable-line
+
+ useEffect(() => {
+ if (autoRefresh) {
+ intervalRef.current = setInterval(() => loadLogs(false), 3000);
+ }
+ return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
+ }, [autoRefresh, tail]); // eslint-disable-line
+
+ async function loadLogs(showSpinner = true) {
+ if (showSpinner) setLoading(true);
+ try {
+ let data;
+ if (isRemote) {
+ const result = await api.getRemoteContainerLogs(serverId, containerId, tail);
+ data = unwrapRemoteData(result);
+ } else {
+ data = await api.getContainerLogs(containerId, tail);
+ }
+ setLogs(data.logs || '');
+ if (autoRefresh && contentRef.current) {
+ contentRef.current.scrollTop = contentRef.current.scrollHeight;
+ }
+ } catch (err) {
+ setLogs('Failed to load logs: ' + (err.message || 'Unknown error'));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function handleDownload() {
+ if (!logs) return;
+ const blob = new Blob([logs], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${container.name}-${Date.now()}.log`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+const ContainerExecModal = ({ container, onClose }) => {
+ const containerId = getContainerId(container);
+ const [command, setCommand] = useState('');
+ const [output, setOutput] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [history, setHistory] = useState([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const outputRef = React.useRef(null);
+ const inputRef = React.useRef(null);
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, []);
+
+ useEffect(() => {
+ if (outputRef.current) {
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
+ }
+ }, [output]);
+
+ async function executeCommand(e) {
+ e.preventDefault();
+ if (!command.trim() || loading) return;
+
+ const cmd = command.trim();
+ setOutput(prev => [...prev, { type: 'command', text: `$ ${cmd}` }]);
+ setHistory(prev => [cmd, ...prev.slice(0, 49)]);
+ setHistoryIndex(-1);
+ setCommand('');
+ setLoading(true);
+
+ try {
+ const result = await api.execContainer(containerId, cmd);
+ const stdout = result.output ?? result.stdout;
+ const stderr = result.error ?? result.stderr;
+ const exitCode = result.exit_code ?? result.return_code;
+ if (stdout) {
+ setOutput(prev => [...prev, { type: 'output', text: stdout }]);
+ }
+ if (stderr) {
+ setOutput(prev => [...prev, { type: 'error', text: stderr }]);
+ }
+ if (exitCode !== undefined && exitCode !== 0) {
+ setOutput(prev => [...prev, { type: 'info', text: `Exit code: ${exitCode}` }]);
+ }
+ } catch (err) {
+ setOutput(prev => [...prev, { type: 'error', text: err.message || 'Failed to execute command' }]);
+ } finally {
+ setLoading(false);
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }
+ }
+
+ function handleKeyDown(e) {
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (history.length > 0 && historyIndex < history.length - 1) {
+ const newIndex = historyIndex + 1;
+ setHistoryIndex(newIndex);
+ setCommand(history[newIndex]);
+ }
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (historyIndex > 0) {
+ const newIndex = historyIndex - 1;
+ setHistoryIndex(newIndex);
+ setCommand(history[newIndex]);
+ } else if (historyIndex === 0) {
+ setHistoryIndex(-1);
+ setCommand('');
+ }
+ }
+ }
+
+ function clearOutput() {
+ setOutput([]);
+ }
+
+ return (
+
+
+
+ {output.length === 0 ? (
+
+
Execute commands in container {getContainerName(container)}
+
Type a command and press Enter
+
+ ) : (
+ output.map((line, idx) => (
+
+ ))
+ )}
+ {loading && (
+
+ Running...
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ContainersTab;
diff --git a/frontend/src/components/docker/ImagesTab.jsx b/frontend/src/components/docker/ImagesTab.jsx
new file mode 100644
index 00000000..02ef6d22
--- /dev/null
+++ b/frontend/src/components/docker/ImagesTab.jsx
@@ -0,0 +1,216 @@
+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 { Input } from '@/components/ui/input';
+import { Layers } from 'lucide-react';
+import {
+ useServer,
+ normalizeListResponse,
+} from './dockerHelpers';
+import { IconAction, TrashIcon } from './dockerShared';
+
+export const PullImageButton = () => {
+ const [showModal, setShowModal] = useState(false);
+ return (
+ <>
+
+ {showModal && setShowModal(false)} onPulled={() => window.location.reload()} />}
+ >
+ );
+};
+
+// Images Tab
+const ImagesTab = ({ onStatsChange }) => {
+ const toast = useToast();
+ const { serverId, isRemote } = useServer();
+ const { confirm: confirmImage } = useConfirm();
+ const [images, setImages] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState('');
+
+ useEffect(() => {
+ loadImages();
+ }, [serverId]);
+
+ async function loadImages() {
+ setLoading(true);
+ try {
+ let data;
+ if (isRemote) {
+ const result = await api.getRemoteImages(serverId);
+ data = { images: normalizeListResponse(result, 'images') };
+ } else {
+ data = await api.getImages();
+ }
+ setImages(data.images || []);
+ } catch (err) {
+ console.error('Failed to load images:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleRemove(imageId) {
+ const confirmed = await confirmImage({ title: 'Remove Image', message: 'Remove this image?' });
+ if (!confirmed) return;
+
+ try {
+ if (isRemote) {
+ await api.removeRemoteImage(serverId, imageId, true);
+ } else {
+ await api.removeImage(imageId, true);
+ }
+ toast.success('Image removed successfully');
+ loadImages();
+ onStatsChange?.();
+ } catch (err) {
+ console.error('Failed to remove image:', err);
+ toast.error('Failed to remove image. It may be in use by a container.');
+ }
+ }
+
+ const filteredImages = images.filter(img => {
+ if (!searchTerm) return true;
+ const search = searchTerm.toLowerCase();
+ return img.repository?.toLowerCase().includes(search) ||
+ img.tag?.toLowerCase().includes(search) ||
+ img.id?.toLowerCase().includes(search);
+ });
+
+ if (loading) {
+ return Loading images...
;
+ }
+
+ return (
+
+
+
+
setSearchTerm(e.target.value)}
+ />
+
+
+ {filteredImages.length === 0 ? (
+
+ ) : (
+
+
+
+ | Repository |
+ Tag |
+ Image ID |
+ Size |
+ Created |
+ Actions |
+
+
+
+ {filteredImages.map(image => (
+
+ |
+ {image.repository || ''}
+ |
+
+ {image.tag || ''}
+ |
+
+ {image.id?.substring(0, 12)}
+ |
+ {image.size} |
+ {image.created} |
+
+ handleRemove(image.id)} color="#EF4444">
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const PullImageModal = ({ onClose, onPulled }) => {
+ const { serverId, isRemote } = useServer();
+ const [image, setImage] = useState('');
+ const [tag, setTag] = useState('latest');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ if (isRemote) {
+ const fullImage = tag ? `${image}:${tag}` : image;
+ await api.pullRemoteImage(serverId, fullImage);
+ } else {
+ await api.pullImage(image, tag);
+ }
+ onPulled();
+ onClose();
+ } catch (err) {
+ setError(err.message || 'Failed to pull image');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ {error && {error}
}
+
+
+
+ );
+};
+
+export default ImagesTab;
diff --git a/frontend/src/components/docker/NetworksTab.jsx b/frontend/src/components/docker/NetworksTab.jsx
new file mode 100644
index 00000000..0ccec1f5
--- /dev/null
+++ b/frontend/src/components/docker/NetworksTab.jsx
@@ -0,0 +1,194 @@
+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 { Input } from '@/components/ui/input';
+import { Network as NetworkIcon } from 'lucide-react';
+import {
+ useServer,
+ normalizeListResponse,
+} from './dockerHelpers';
+import { IconAction, TrashIcon } from './dockerShared';
+
+export const CreateNetworkButton = () => {
+ const [showModal, setShowModal] = useState(false);
+ const { isRemote } = useServer();
+ return (
+ <>
+
+ {showModal && setShowModal(false)} onCreated={() => window.location.reload()} />}
+ >
+ );
+};
+
+// Networks Tab
+const NetworksTab = ({ onStatsChange }) => {
+ const toast = useToast();
+ const { serverId, isRemote } = useServer();
+ const { confirm: confirmNetwork } = useConfirm();
+ const [networks, setNetworks] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadNetworks();
+ }, [serverId]);
+
+ async function loadNetworks() {
+ setLoading(true);
+ try {
+ let data;
+ if (isRemote) {
+ const result = await api.getRemoteNetworks(serverId);
+ data = { networks: normalizeListResponse(result, 'networks') };
+ } else {
+ data = await api.getNetworks();
+ }
+ setNetworks(data.networks || []);
+ } catch (err) {
+ console.error('Failed to load networks:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleRemove(networkId) {
+ const confirmed = await confirmNetwork({ title: 'Remove Network', message: 'Remove this network?' });
+ if (!confirmed) return;
+
+ try {
+ if (isRemote) {
+ await api.removeRemoteNetwork(serverId, networkId);
+ } else {
+ await api.removeNetwork(networkId);
+ }
+ toast.success('Network removed successfully');
+ loadNetworks();
+ onStatsChange?.();
+ } catch (err) {
+ console.error('Failed to remove network:', err);
+ toast.error('Failed to remove network. It may be in use.');
+ }
+ }
+
+ const systemNetworks = ['bridge', 'host', 'none'];
+
+ if (loading) {
+ return Loading networks...
;
+ }
+
+ return (
+
+ {networks.length === 0 ? (
+
+ ) : (
+
+
+
+ | Name |
+ Network ID |
+ Driver |
+ Scope |
+ Actions |
+
+
+
+ {networks.map(network => (
+
+ |
+ {network.name}
+ |
+
+ {network.id?.substring(0, 12)}
+ |
+ {network.driver} |
+ {network.scope} |
+
+ {!systemNetworks.includes(network.name) && (
+ handleRemove(network.id)} color="#EF4444">
+
+
+ )}
+ |
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const CreateNetworkModal = ({ onClose, onCreated }) => {
+ const [name, setName] = useState('');
+ const [driver, setDriver] = useState('bridge');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await api.createNetwork(name, driver);
+ onCreated();
+ onClose();
+ } catch (err) {
+ setError(err.message || 'Failed to create network');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ {error && {error}
}
+
+
+
+ );
+};
+
+export default NetworksTab;
diff --git a/frontend/src/components/docker/PruneButton.jsx b/frontend/src/components/docker/PruneButton.jsx
new file mode 100644
index 00000000..0abedd1f
--- /dev/null
+++ b/frontend/src/components/docker/PruneButton.jsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import api from '../../services/api';
+import { useToast } from '../../contexts/ToastContext';
+import { useConfirm } from '../../hooks/useConfirm';
+import { Button } from '@/components/ui/button';
+import { useServer } from './dockerHelpers';
+
+const PruneButton = ({ onPruned }) => {
+ const toast = useToast();
+ const { isRemote } = useServer();
+ const [loading, setLoading] = useState(false);
+ const { confirm } = useConfirm();
+
+ async function handlePrune() {
+ if (isRemote) {
+ toast.error('Prune is only available on the local Docker target right now');
+ return;
+ }
+ const confirmed = await confirm({ title: 'Docker Cleanup', message: 'Remove unused Docker resources? This will remove stopped containers, unused images, and unused networks.' });
+ if (!confirmed) return;
+
+ setLoading(true);
+ try {
+ await api.request('/docker/cleanup', { method: 'POST', body: {} });
+ toast.success('Docker cleanup completed');
+ onPruned?.();
+ } catch {
+ toast.error('Failed to cleanup Docker resources');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default PruneButton;
diff --git a/frontend/src/components/docker/VolumesTab.jsx b/frontend/src/components/docker/VolumesTab.jsx
new file mode 100644
index 00000000..2e00b884
--- /dev/null
+++ b/frontend/src/components/docker/VolumesTab.jsx
@@ -0,0 +1,180 @@
+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 { Input } from '@/components/ui/input';
+import { HardDrive } from 'lucide-react';
+import {
+ useServer,
+ normalizeListResponse,
+} from './dockerHelpers';
+import { IconAction, TrashIcon } from './dockerShared';
+
+export const CreateVolumeButton = () => {
+ const [showModal, setShowModal] = useState(false);
+ const { isRemote } = useServer();
+ return (
+ <>
+
+ {showModal && setShowModal(false)} onCreated={() => window.location.reload()} />}
+ >
+ );
+};
+
+// Volumes Tab
+const VolumesTab = ({ onStatsChange }) => {
+ const toast = useToast();
+ const { serverId, isRemote } = useServer();
+ const { confirm: confirmVolume } = useConfirm();
+ const [volumes, setVolumes] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadVolumes();
+ }, [serverId]);
+
+ async function loadVolumes() {
+ setLoading(true);
+ try {
+ let data;
+ if (isRemote) {
+ const result = await api.getRemoteVolumes(serverId);
+ data = { volumes: normalizeListResponse(result, 'volumes') };
+ } else {
+ data = await api.getVolumes();
+ }
+ setVolumes(data.volumes || []);
+ } catch (err) {
+ console.error('Failed to load volumes:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleRemove(volumeName) {
+ const confirmed = await confirmVolume({ title: 'Remove Volume', message: 'Remove this volume? All data will be lost.' });
+ if (!confirmed) return;
+
+ try {
+ if (isRemote) {
+ await api.removeRemoteVolume(serverId, volumeName, true);
+ } else {
+ await api.removeVolume(volumeName, true);
+ }
+ toast.success('Volume removed successfully');
+ loadVolumes();
+ onStatsChange?.();
+ } catch (err) {
+ console.error('Failed to remove volume:', err);
+ toast.error('Failed to remove volume. It may be in use.');
+ }
+ }
+
+ if (loading) {
+ return Loading volumes...
;
+ }
+
+ return (
+
+ {volumes.length === 0 ? (
+
+ ) : (
+
+
+
+ | Name |
+ Driver |
+ Mountpoint |
+ Actions |
+
+
+
+ {volumes.map(volume => (
+
+ |
+ {volume.name}
+ |
+ {volume.driver} |
+
+
+ {volume.mountpoint || '-'}
+
+ |
+
+ handleRemove(volume.name)} color="#EF4444">
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const CreateVolumeModal = ({ onClose, onCreated }) => {
+ const [name, setName] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await api.createVolume(name);
+ onCreated();
+ onClose();
+ } catch (err) {
+ setError(err.message || 'Failed to create volume');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ {error && {error}
}
+
+
+
+ );
+};
+
+export default VolumesTab;
diff --git a/frontend/src/components/docker/dockerHelpers.js b/frontend/src/components/docker/dockerHelpers.js
new file mode 100644
index 00000000..5c2d9ffb
--- /dev/null
+++ b/frontend/src/components/docker/dockerHelpers.js
@@ -0,0 +1,108 @@
+import { createContext, useContext } from 'react';
+
+// Server context for Docker operations
+export const ServerContext = createContext({ serverId: 'local', serverName: 'Local' });
+export const useServer = () => useContext(ServerContext);
+
+export const VALID_TABS = ['containers', 'compose', 'images', 'volumes', 'networks'];
+export const LOCAL_DOCKER_TARGET = { id: 'local', name: 'Local (this server)', status: 'online', is_local: true };
+
+export const unwrapRemoteData = (response) => {
+ if (response?.success && response.data !== undefined) {
+ return response.data;
+ }
+ return response;
+};
+
+// formatPorts normalises Docker port data from the two shapes the agent
+// returns: a comma-separated string (legacy `docker ps`-style output)
+// or an array of `{ip, private_port, public_port, type}` objects from
+// `docker inspect`. Always returns an array of human-readable strings,
+// or `['-']` when there are no ports — both call sites (the container
+// list grid and the inspector drawer) want array semantics. Rendering
+// the raw inspect array directly triggered React error #31.
+export function formatPorts(ports) {
+ if (!ports) return ['-'];
+ if (Array.isArray(ports)) {
+ const formatted = ports
+ .map((p) => {
+ if (!p || typeof p !== 'object') return null;
+ const proto = p.type || p.protocol || 'tcp';
+ const priv = p.private_port ?? p.PrivatePort;
+ const pub = p.public_port ?? p.PublicPort;
+ const ip = p.ip || p.IP;
+ if (pub) {
+ return `${ip ? `${ip}:` : ''}${pub}->${priv}/${proto}`;
+ }
+ return priv ? `${priv}/${proto}` : null;
+ })
+ .filter(Boolean);
+ return formatted.length > 0 ? formatted : ['-'];
+ }
+ if (typeof ports !== 'string') return ['-'];
+ const parts = ports.split(',').map((p) => p.trim()).filter(Boolean);
+ return parts.length > 0 ? parts : ['-'];
+}
+
+export const normalizeListResponse = (response, key) => {
+ const data = unwrapRemoteData(response);
+ if (Array.isArray(data)) return data;
+ if (Array.isArray(data?.[key])) return data[key];
+ return [];
+};
+
+export const shortId = (value) => value ? value.substring(0, 12) : '-';
+
+export const getContainerId = (container) => (
+ container?.id || container?.ID || container?.Id || ''
+);
+
+export const getContainerName = (container) => (
+ container?.name || container?.Names || container?.Name || 'unnamed'
+);
+
+export const getContainerImage = (container) => (
+ container?.image || container?.Image || container?.Config?.Image || '-'
+);
+
+export const getContainerStatus = (container) => (
+ container?.status || container?.Status || container?.State?.Status || '-'
+);
+
+export const getContainerState = (container) => {
+ const state = container?.state || container?.State?.Status || container?.State || '';
+ return typeof state === 'string' ? state.toLowerCase() : '';
+};
+
+export const isContainerRunning = (container) => getContainerState(container) === 'running';
+
+// ServerKit's own infrastructure containers run the panel itself, so we don't
+// expose lifecycle controls (start/stop/restart/remove) for them — the backend
+// rejects those calls anyway. Trust the backend `protected` flag when present;
+// fall back to a name check for shapes (e.g. remote agents) that omit it.
+export const SELF_CONTAINER_HINTS = ['serverkit-frontend', 'serverkit_frontend', 'serverkit-backend', 'serverkit_backend'];
+export const isProtectedContainer = (container) => {
+ if (container?.protected === true) return true;
+ const name = getContainerName(container).toLowerCase().replace(/\//g, '');
+ return SELF_CONTAINER_HINTS.some(hint => name.includes(hint));
+};
+
+export const getContainerStatusLabel = (container) => {
+ if (isContainerRunning(container)) return 'Running';
+ const state = getContainerState(container);
+ if (state === 'exited') return 'Exited';
+ if (state === 'created') return 'Created';
+ return state || 'Unknown';
+};
+
+// Deterministic hue from a container name (demo's per-container accent color).
+export const containerHue = (name = '') => {
+ let h = 0;
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
+ return h;
+};
+
+export const getContainerProjectName = (container, details) => {
+ const labels = details?.Config?.Labels || container?.Labels || {};
+ return labels['com.docker.compose.project'] || labels['com.docker.compose.service'] || '-';
+};
diff --git a/frontend/src/components/docker/dockerShared.jsx b/frontend/src/components/docker/dockerShared.jsx
new file mode 100644
index 00000000..2076320e
--- /dev/null
+++ b/frontend/src/components/docker/dockerShared.jsx
@@ -0,0 +1,45 @@
+import { Trash2 } from 'lucide-react';
+
+// Icon Action button used by remaining (legacy) tabs (Images, Networks, Volumes).
+// Containers tab now renders cards with full action buttons inline.
+export const IconAction = ({ title, onClick, color, children, disabled }) => (
+
+);
+
+export const TrashIcon = () => ;
+
+export const ContainerResourceBars = ({ stats, muted = false }) => (
+
+
+
CPU
+
+
{stats.available ? `${stats.cpu.toFixed(1)}%` : '--'}
+
+
+
RAM
+
+
{stats.available ? `${stats.memory.toFixed(1)}%` : '--'}
+
+
+);
+
+// Download Icon for Compose Pull
+export const DownloadIcon = () => (
+
+);
diff --git a/frontend/src/pages/Docker.jsx b/frontend/src/pages/Docker.jsx
index 473ae367..570d81a7 100644
--- a/frontend/src/pages/Docker.jsx
+++ b/frontend/src/pages/Docker.jsx
@@ -1,128 +1,25 @@
-import React, { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } from 'react';
+import { useState, useEffect } from 'react';
import useTabParam from '../hooks/useTabParam';
import api from '../services/api';
-import { useToast } from '../contexts/ToastContext';
-import { useConfirm } from '../hooks/useConfirm';
-import LogToolbar from '../components/log-viewer/LogToolbar';
-import LogContent from '../components/log-viewer/LogContent';
import EmptyState from '../components/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 { MetricCard } from '@/components/ds';
import {
- Box, Layers, HardDrive, Network as NetworkIcon, Search, X, RefreshCw,
- Trash2, Play, Square, RotateCw, Terminal as TerminalLucide, FileText,
- Activity, Clock3, Copy, Database, Gauge, Package, Server as ServerIcon, ArrowUpDown, Lock,
+ Box, Layers, HardDrive, Network as NetworkIcon,
+ Trash2, Activity, Package, Server as ServerIcon,
} from 'lucide-react';
-
-// Server context for Docker operations
-const ServerContext = createContext({ serverId: 'local', serverName: 'Local' });
-const useServer = () => useContext(ServerContext);
-
-const VALID_TABS = ['containers', 'compose', 'images', 'volumes', 'networks'];
-const LOCAL_DOCKER_TARGET = { id: 'local', name: 'Local (this server)', status: 'online', is_local: true };
-
-const unwrapRemoteData = (response) => {
- if (response?.success && response.data !== undefined) {
- return response.data;
- }
- return response;
-};
-
-// formatPorts normalises Docker port data from the two shapes the agent
-// returns: a comma-separated string (legacy `docker ps`-style output)
-// or an array of `{ip, private_port, public_port, type}` objects from
-// `docker inspect`. Always returns an array of human-readable strings,
-// or `['-']` when there are no ports — both call sites (the container
-// list grid and the inspector drawer) want array semantics. Rendering
-// the raw inspect array directly triggered React error #31.
-function formatPorts(ports) {
- if (!ports) return ['-'];
- if (Array.isArray(ports)) {
- const formatted = ports
- .map((p) => {
- if (!p || typeof p !== 'object') return null;
- const proto = p.type || p.protocol || 'tcp';
- const priv = p.private_port ?? p.PrivatePort;
- const pub = p.public_port ?? p.PublicPort;
- const ip = p.ip || p.IP;
- if (pub) {
- return `${ip ? `${ip}:` : ''}${pub}->${priv}/${proto}`;
- }
- return priv ? `${priv}/${proto}` : null;
- })
- .filter(Boolean);
- return formatted.length > 0 ? formatted : ['-'];
- }
- if (typeof ports !== 'string') return ['-'];
- const parts = ports.split(',').map((p) => p.trim()).filter(Boolean);
- return parts.length > 0 ? parts : ['-'];
-}
-
-const normalizeListResponse = (response, key) => {
- const data = unwrapRemoteData(response);
- if (Array.isArray(data)) return data;
- if (Array.isArray(data?.[key])) return data[key];
- return [];
-};
-
-const shortId = (value) => value ? value.substring(0, 12) : '-';
-
-const getContainerId = (container) => (
- container?.id || container?.ID || container?.Id || ''
-);
-
-const getContainerName = (container) => (
- container?.name || container?.Names || container?.Name || 'unnamed'
-);
-
-const getContainerImage = (container) => (
- container?.image || container?.Image || container?.Config?.Image || '-'
-);
-
-const getContainerStatus = (container) => (
- container?.status || container?.Status || container?.State?.Status || '-'
-);
-
-const getContainerState = (container) => {
- const state = container?.state || container?.State?.Status || container?.State || '';
- return typeof state === 'string' ? state.toLowerCase() : '';
-};
-
-const isContainerRunning = (container) => getContainerState(container) === 'running';
-
-// ServerKit's own infrastructure containers run the panel itself, so we don't
-// expose lifecycle controls (start/stop/restart/remove) for them — the backend
-// rejects those calls anyway. Trust the backend `protected` flag when present;
-// fall back to a name check for shapes (e.g. remote agents) that omit it.
-const SELF_CONTAINER_HINTS = ['serverkit-frontend', 'serverkit_frontend', 'serverkit-backend', 'serverkit_backend'];
-const isProtectedContainer = (container) => {
- if (container?.protected === true) return true;
- const name = getContainerName(container).toLowerCase().replace(/\//g, '');
- return SELF_CONTAINER_HINTS.some(hint => name.includes(hint));
-};
-
-const getContainerStatusLabel = (container) => {
- if (isContainerRunning(container)) return 'Running';
- const state = getContainerState(container);
- if (state === 'exited') return 'Exited';
- if (state === 'created') return 'Created';
- return state || 'Unknown';
-};
-
-// Deterministic hue from a container name (demo's per-container accent color).
-const containerHue = (name = '') => {
- let h = 0;
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
- return h;
-};
-
-const getContainerProjectName = (container, details) => {
- const labels = details?.Config?.Labels || container?.Labels || {};
- return labels['com.docker.compose.project'] || labels['com.docker.compose.service'] || '-';
-};
+import {
+ ServerContext,
+ VALID_TABS,
+ LOCAL_DOCKER_TARGET,
+ normalizeListResponse,
+} from '../components/docker/dockerHelpers';
+import ContainersTab, { RunContainerButton } from '../components/docker/ContainersTab';
+import ComposeTab from '../components/docker/ComposeTab';
+import ImagesTab, { PullImageButton } from '../components/docker/ImagesTab';
+import NetworksTab, { CreateNetworkButton } from '../components/docker/NetworksTab';
+import VolumesTab, { CreateVolumeButton } from '../components/docker/VolumesTab';
+import PruneButton from '../components/docker/PruneButton';
const Docker = () => {
const [activeTab, setActiveTab] = useTabParam('/docker', VALID_TABS);
@@ -459,2063 +356,4 @@ const Docker = () => {
);
};
-// Action Buttons
-const RunContainerButton = () => {
- const [showModal, setShowModal] = useState(false);
- const { isRemote } = useServer();
- return (
- <>
-
- {showModal && setShowModal(false)} onCreated={() => window.location.reload()} />}
- >
- );
-};
-
-const PullImageButton = () => {
- const [showModal, setShowModal] = useState(false);
- return (
- <>
-
- {showModal && setShowModal(false)} onPulled={() => window.location.reload()} />}
- >
- );
-};
-
-const CreateNetworkButton = () => {
- const [showModal, setShowModal] = useState(false);
- const { isRemote } = useServer();
- return (
- <>
-
- {showModal && setShowModal(false)} onCreated={() => window.location.reload()} />}
- >
- );
-};
-
-const CreateVolumeButton = () => {
- const [showModal, setShowModal] = useState(false);
- const { isRemote } = useServer();
- return (
- <>
-
- {showModal && setShowModal(false)} onCreated={() => window.location.reload()} />}
- >
- );
-};
-
-const PruneButton = ({ onPruned }) => {
- const toast = useToast();
- const { isRemote } = useServer();
- const [loading, setLoading] = useState(false);
- const { confirm } = useConfirm();
-
- async function handlePrune() {
- if (isRemote) {
- toast.error('Prune is only available on the local Docker target right now');
- return;
- }
- const confirmed = await confirm({ title: 'Docker Cleanup', message: 'Remove unused Docker resources? This will remove stopped containers, unused images, and unused networks.' });
- if (!confirmed) return;
-
- setLoading(true);
- try {
- await api.request('/docker/cleanup', { method: 'POST', body: {} });
- toast.success('Docker cleanup completed');
- onPruned?.();
- } catch {
- toast.error('Failed to cleanup Docker resources');
- } finally {
- setLoading(false);
- }
- }
-
- return (
- <>
-
- >
- );
-};
-
-// Icon Action button used by remaining (legacy) tabs (Images, Networks, Volumes).
-// Containers tab now renders cards with full action buttons inline.
-const IconAction = ({ title, onClick, color, children, disabled }) => (
-
-);
-
-const TrashIcon = () => ;
-
-// 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 ? (
-
- ) : (
-
-
-
-
-
-
- | Name |
- Image |
- Status |
- Ports |
- Resources |
- Created |
- Actions |
-
-
-
- {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)}
- >
- |
-
-
- {/* 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 ContainerResourceBars = ({ stats, muted = false }) => (
-
-
-
CPU
-
-
{stats.available ? `${stats.cpu.toFixed(1)}%` : '--'}
-
-
-
RAM
-
-
{stats.available ? `${stats.memory.toFixed(1)}%` : '--'}
-
-
-);
-
-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 (
- <>
-
-
- >
- );
-};
-
-// Images Tab
-const ImagesTab = ({ onStatsChange }) => {
- const toast = useToast();
- const { serverId, isRemote } = useServer();
- const { confirm: confirmImage } = useConfirm();
- const [images, setImages] = useState([]);
- const [loading, setLoading] = useState(true);
- const [searchTerm, setSearchTerm] = useState('');
-
- useEffect(() => {
- loadImages();
- }, [serverId]);
-
- async function loadImages() {
- setLoading(true);
- try {
- let data;
- if (isRemote) {
- const result = await api.getRemoteImages(serverId);
- data = { images: normalizeListResponse(result, 'images') };
- } else {
- data = await api.getImages();
- }
- setImages(data.images || []);
- } catch (err) {
- console.error('Failed to load images:', err);
- } finally {
- setLoading(false);
- }
- }
-
- async function handleRemove(imageId) {
- const confirmed = await confirmImage({ title: 'Remove Image', message: 'Remove this image?' });
- if (!confirmed) return;
-
- try {
- if (isRemote) {
- await api.removeRemoteImage(serverId, imageId, true);
- } else {
- await api.removeImage(imageId, true);
- }
- toast.success('Image removed successfully');
- loadImages();
- onStatsChange?.();
- } catch (err) {
- console.error('Failed to remove image:', err);
- toast.error('Failed to remove image. It may be in use by a container.');
- }
- }
-
- const filteredImages = images.filter(img => {
- if (!searchTerm) return true;
- const search = searchTerm.toLowerCase();
- return img.repository?.toLowerCase().includes(search) ||
- img.tag?.toLowerCase().includes(search) ||
- img.id?.toLowerCase().includes(search);
- });
-
- if (loading) {
- return Loading images...
;
- }
-
- return (
-
-
-
-
setSearchTerm(e.target.value)}
- />
-
-
- {filteredImages.length === 0 ? (
-
- ) : (
-
-
-
- | Repository |
- Tag |
- Image ID |
- Size |
- Created |
- Actions |
-
-
-
- {filteredImages.map(image => (
-
- |
- {image.repository || ''}
- |
-
- {image.tag || ''}
- |
-
- {image.id?.substring(0, 12)}
- |
- {image.size} |
- {image.created} |
-
- handleRemove(image.id)} color="#EF4444">
-
-
- |
-
- ))}
-
-
- )}
-
- );
-};
-
-// Networks Tab
-const NetworksTab = ({ onStatsChange }) => {
- const toast = useToast();
- const { serverId, isRemote } = useServer();
- const { confirm: confirmNetwork } = useConfirm();
- const [networks, setNetworks] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- loadNetworks();
- }, [serverId]);
-
- async function loadNetworks() {
- setLoading(true);
- try {
- let data;
- if (isRemote) {
- const result = await api.getRemoteNetworks(serverId);
- data = { networks: normalizeListResponse(result, 'networks') };
- } else {
- data = await api.getNetworks();
- }
- setNetworks(data.networks || []);
- } catch (err) {
- console.error('Failed to load networks:', err);
- } finally {
- setLoading(false);
- }
- }
-
- async function handleRemove(networkId) {
- const confirmed = await confirmNetwork({ title: 'Remove Network', message: 'Remove this network?' });
- if (!confirmed) return;
-
- try {
- if (isRemote) {
- await api.removeRemoteNetwork(serverId, networkId);
- } else {
- await api.removeNetwork(networkId);
- }
- toast.success('Network removed successfully');
- loadNetworks();
- onStatsChange?.();
- } catch (err) {
- console.error('Failed to remove network:', err);
- toast.error('Failed to remove network. It may be in use.');
- }
- }
-
- const systemNetworks = ['bridge', 'host', 'none'];
-
- if (loading) {
- return Loading networks...
;
- }
-
- return (
-
- {networks.length === 0 ? (
-
- ) : (
-
-
-
- | Name |
- Network ID |
- Driver |
- Scope |
- Actions |
-
-
-
- {networks.map(network => (
-
- |
- {network.name}
- |
-
- {network.id?.substring(0, 12)}
- |
- {network.driver} |
- {network.scope} |
-
- {!systemNetworks.includes(network.name) && (
- handleRemove(network.id)} color="#EF4444">
-
-
- )}
- |
-
- ))}
-
-
- )}
-
- );
-};
-
-// Volumes Tab
-const VolumesTab = ({ onStatsChange }) => {
- const toast = useToast();
- const { serverId, isRemote } = useServer();
- const { confirm: confirmVolume } = useConfirm();
- const [volumes, setVolumes] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- loadVolumes();
- }, [serverId]);
-
- async function loadVolumes() {
- setLoading(true);
- try {
- let data;
- if (isRemote) {
- const result = await api.getRemoteVolumes(serverId);
- data = { volumes: normalizeListResponse(result, 'volumes') };
- } else {
- data = await api.getVolumes();
- }
- setVolumes(data.volumes || []);
- } catch (err) {
- console.error('Failed to load volumes:', err);
- } finally {
- setLoading(false);
- }
- }
-
- async function handleRemove(volumeName) {
- const confirmed = await confirmVolume({ title: 'Remove Volume', message: 'Remove this volume? All data will be lost.' });
- if (!confirmed) return;
-
- try {
- if (isRemote) {
- await api.removeRemoteVolume(serverId, volumeName, true);
- } else {
- await api.removeVolume(volumeName, true);
- }
- toast.success('Volume removed successfully');
- loadVolumes();
- onStatsChange?.();
- } catch (err) {
- console.error('Failed to remove volume:', err);
- toast.error('Failed to remove volume. It may be in use.');
- }
- }
-
- if (loading) {
- return Loading volumes...
;
- }
-
- return (
-
- {volumes.length === 0 ? (
-
- ) : (
-
-
-
- | Name |
- Driver |
- Mountpoint |
- Actions |
-
-
-
- {volumes.map(volume => (
-
- |
- {volume.name}
- |
- {volume.driver} |
-
-
- {volume.mountpoint || '-'}
-
- |
-
- handleRemove(volume.name)} color="#EF4444">
-
-
- |
-
- ))}
-
-
- )}
-
- );
-};
-
-// 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}
- />
- ) : (
-
-
-
- | Project |
- Status |
- Config File |
- Actions |
-
-
-
- {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 (
-
- |
- {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)}
- />
- )}
-
- );
-};
-
-// Download Icon for Compose Pull
-const DownloadIcon = () => (
-
-);
-
-// 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}
-
-
-
-
-
-
- );
-};
-
-// Modals
-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}
}
-
-
-
- );
-};
-
-const ContainerLogsModal = ({ container, onClose }) => {
- const { serverId, isRemote } = useServer();
- const containerId = getContainerId(container);
- const [logs, setLogs] = useState('');
- const [loading, setLoading] = useState(true);
- const [tail, setTail] = useState(200);
- const [searchPattern, setSearchPattern] = useState('');
- const [appliedSearch, setAppliedSearch] = useState('');
- const [autoRefresh, setAutoRefresh] = useState(false);
- const [showLineNumbers, setShowLineNumbers] = useState(true);
- const [wrapLines, setWrapLines] = useState(true);
- const contentRef = useRef(null);
- const intervalRef = useRef(null);
-
- useEffect(() => {
- loadLogs();
- }, [container, tail]); // eslint-disable-line
-
- useEffect(() => {
- if (autoRefresh) {
- intervalRef.current = setInterval(() => loadLogs(false), 3000);
- }
- return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
- }, [autoRefresh, tail]); // eslint-disable-line
-
- async function loadLogs(showSpinner = true) {
- if (showSpinner) setLoading(true);
- try {
- let data;
- if (isRemote) {
- const result = await api.getRemoteContainerLogs(serverId, containerId, tail);
- data = unwrapRemoteData(result);
- } else {
- data = await api.getContainerLogs(containerId, tail);
- }
- setLogs(data.logs || '');
- if (autoRefresh && contentRef.current) {
- contentRef.current.scrollTop = contentRef.current.scrollHeight;
- }
- } catch (err) {
- setLogs('Failed to load logs: ' + (err.message || 'Unknown error'));
- } finally {
- setLoading(false);
- }
- }
-
- function handleDownload() {
- if (!logs) return;
- const blob = new Blob([logs], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `${container.name}-${Date.now()}.log`;
- a.click();
- URL.revokeObjectURL(url);
- }
-
- return (
- <>
-
-
- >
- );
-};
-
-const ContainerExecModal = ({ container, onClose }) => {
- const containerId = getContainerId(container);
- const [command, setCommand] = useState('');
- const [output, setOutput] = useState([]);
- const [loading, setLoading] = useState(false);
- const [history, setHistory] = useState([]);
- const [historyIndex, setHistoryIndex] = useState(-1);
- const outputRef = React.useRef(null);
- const inputRef = React.useRef(null);
-
- useEffect(() => {
- if (inputRef.current) {
- inputRef.current.focus();
- }
- }, []);
-
- useEffect(() => {
- if (outputRef.current) {
- outputRef.current.scrollTop = outputRef.current.scrollHeight;
- }
- }, [output]);
-
- async function executeCommand(e) {
- e.preventDefault();
- if (!command.trim() || loading) return;
-
- const cmd = command.trim();
- setOutput(prev => [...prev, { type: 'command', text: `$ ${cmd}` }]);
- setHistory(prev => [cmd, ...prev.slice(0, 49)]);
- setHistoryIndex(-1);
- setCommand('');
- setLoading(true);
-
- try {
- const result = await api.execContainer(containerId, cmd);
- const stdout = result.output ?? result.stdout;
- const stderr = result.error ?? result.stderr;
- const exitCode = result.exit_code ?? result.return_code;
- if (stdout) {
- setOutput(prev => [...prev, { type: 'output', text: stdout }]);
- }
- if (stderr) {
- setOutput(prev => [...prev, { type: 'error', text: stderr }]);
- }
- if (exitCode !== undefined && exitCode !== 0) {
- setOutput(prev => [...prev, { type: 'info', text: `Exit code: ${exitCode}` }]);
- }
- } catch (err) {
- setOutput(prev => [...prev, { type: 'error', text: err.message || 'Failed to execute command' }]);
- } finally {
- setLoading(false);
- if (inputRef.current) {
- inputRef.current.focus();
- }
- }
- }
-
- function handleKeyDown(e) {
- if (e.key === 'ArrowUp') {
- e.preventDefault();
- if (history.length > 0 && historyIndex < history.length - 1) {
- const newIndex = historyIndex + 1;
- setHistoryIndex(newIndex);
- setCommand(history[newIndex]);
- }
- } else if (e.key === 'ArrowDown') {
- e.preventDefault();
- if (historyIndex > 0) {
- const newIndex = historyIndex - 1;
- setHistoryIndex(newIndex);
- setCommand(history[newIndex]);
- } else if (historyIndex === 0) {
- setHistoryIndex(-1);
- setCommand('');
- }
- }
- }
-
- function clearOutput() {
- setOutput([]);
- }
-
- return (
-
-
-
- {output.length === 0 ? (
-
-
Execute commands in container {getContainerName(container)}
-
Type a command and press Enter
-
- ) : (
- output.map((line, idx) => (
-
- ))
- )}
- {loading && (
-
- Running...
-
- )}
-
-
-
-
-
-
-
-
- );
-};
-
-const PullImageModal = ({ onClose, onPulled }) => {
- const { serverId, isRemote } = useServer();
- const [image, setImage] = useState('');
- const [tag, setTag] = useState('latest');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
-
- async function handleSubmit(e) {
- e.preventDefault();
- setError('');
- setLoading(true);
-
- try {
- if (isRemote) {
- const fullImage = tag ? `${image}:${tag}` : image;
- await api.pullRemoteImage(serverId, fullImage);
- } else {
- await api.pullImage(image, tag);
- }
- onPulled();
- onClose();
- } catch (err) {
- setError(err.message || 'Failed to pull image');
- } finally {
- setLoading(false);
- }
- }
-
- return (
-
- {error && {error}
}
-
-
-
- );
-};
-
-const CreateNetworkModal = ({ onClose, onCreated }) => {
- const [name, setName] = useState('');
- const [driver, setDriver] = useState('bridge');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
-
- async function handleSubmit(e) {
- e.preventDefault();
- setError('');
- setLoading(true);
-
- try {
- await api.createNetwork(name, driver);
- onCreated();
- onClose();
- } catch (err) {
- setError(err.message || 'Failed to create network');
- } finally {
- setLoading(false);
- }
- }
-
- return (
-
- {error && {error}
}
-
-
-
- );
-};
-
-const CreateVolumeModal = ({ onClose, onCreated }) => {
- const [name, setName] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
-
- async function handleSubmit(e) {
- e.preventDefault();
- setError('');
- setLoading(true);
-
- try {
- await api.createVolume(name);
- onCreated();
- onClose();
- } catch (err) {
- setError(err.message || 'Failed to create volume');
- } finally {
- setLoading(false);
- }
- }
-
- return (
-
- {error && {error}
}
-
-
-
- );
-};
-
export default Docker;