import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react"; import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react"; import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent } from "../../shared/types"; type Tab = "info" | "config" | "env" | "stats"; const SYSTEM_ENV_KEYS = new Set([ "PATH", "HOME", "HOSTNAME", "TERM", "SHLVL", "PWD", "OLDPWD", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "LC_MESSAGES", "LC_COLLATE", "DEBIAN_FRONTEND", "GPG_KEY", "GPG_KEYS", "PYTHON_VERSION", "PYTHON_PIP_VERSION", "PYTHON_SETUPTOOLS_VERSION", "PYTHON_GET_PIP_URL", "PYTHON_GET_PIP_SHA256", "PYTHON_SHA256", "NODE_VERSION", "YARN_VERSION", "NPM_CONFIG_LOGLEVEL", "JAVA_HOME", "JAVA_VERSION", "GOPATH", "GOVERSION", "PHPIZE_DEPS", "PHP_VERSION", "PHP_INI_DIR", "RUBY_VERSION", "GEM_HOME", "BUNDLE_PATH", "NGINX_VERSION", "NJS_VERSION", "REDIS_VERSION", "REDIS_DOWNLOAD_URL", "REDIS_DOWNLOAD_SHA", "PGDATA", "PG_MAJOR", "PG_VERSION", "PG_SHA256", "MYSQL_MAJOR", "MYSQL_VERSION", "MYSQL_SHELL_VERSION", "MONGO_VERSION", "MONGO_MAJOR", "MONGO_PACKAGE", "MONGO_REPO", ]); const TABS: { id: Tab; label: string; icon: typeof InfoIcon }[] = [ { id: "info", label: "Info", icon: InfoIcon }, { id: "stats", label: "Stats", icon: Activity }, { id: "env", label: "Env", icon: Variable }, { id: "config", label: "Config", icon: Settings }, ]; interface DetailPanelProps { service: Service; stats?: Stats; logLines: LogLine[]; token: string; closing?: boolean; onClose: () => void; onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void; clearProcessing: (uid: string) => void; sendMessage: (msg: WSMessage) => void; clearLogLines: () => void; connections: Connection[]; services: Service[]; getLogsSince: (uid: string) => number | undefined; initialLogsFullscreen?: boolean; envFiles: Record; onEnvFileChange: (composeFile: string, envFile: string | null) => void; events: DockerEvent[]; } export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, clearProcessing, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) { const [initialLogs, setInitialLogs] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const [loading, setLoading] = useState(true); const scrollRef = useRef(null); const subscribedRef = useRef(null); const [visible, setVisible] = useState(false); const [activeTab, setActiveTab] = useState("info"); const [logsExpanded, setLogsExpanded] = useState(false); const [envVisibleAll, setEnvVisibleAll] = useState(false); const [envVisibleSet, setEnvVisibleSet] = useState>(new Set()); const [copiedEnvIdx, setCopiedEnvIdx] = useState(null); const [logsModal, setLogsModal] = useState(!!initialLogsFullscreen); const modalScrollRef = useRef(null); const [envFileEditing, setEnvFileEditing] = useState(false); const [envFileOptions, setEnvFileOptions] = useState([]); const [envFileSelected, setEnvFileSelected] = useState(""); // Exec state const [execOpen, setExecOpen] = useState(false); const [execCmd, setExecCmd] = useState(""); const [execLoading, setExecLoading] = useState(false); const [execResult, setExecResult] = useState<{ output: string; exitCode: number } | null>(null); const [execError, setExecError] = useState(null); // Scroll modal to bottom when opened or when logs arrive useEffect(() => { if (logsModal && modalScrollRef.current) { modalScrollRef.current.scrollTop = modalScrollRef.current.scrollHeight; } }, [logsModal, initialLogs, logLines]); const isProcessing = (service.state as string) === "processing"; const processingStartedAt = (service as any)._processingStartedAt as number | undefined; const [elapsed, setElapsed] = useState(0); const prevProcessingRef = useRef(false); const actionTimestampRef = useRef(null); // Timer for processing counter useEffect(() => { if (!isProcessing || !processingStartedAt) { setElapsed(0); return; } setElapsed(Math.floor((Date.now() - processingStartedAt) / 1000)); const interval = setInterval(() => { setElapsed(Math.floor((Date.now() - processingStartedAt) / 1000)); }, 1000); return () => clearInterval(interval); }, [isProcessing, processingStartedAt]); const [actionLoading, setActionLoading] = useState(null); const [actionResult, setActionResult] = useState<{ type: "success" | "error"; message: string } | null>(null); const [confirmAction, setConfirmAction] = useState<"stop" | "restart" | "rebuild" | "remove" | null>(null); const executeAction = useCallback(async (action: "stop" | "start" | "restart" | "rebuild" | "remove") => { setActionLoading(action); setActionResult(null); setConfirmAction(null); actionTimestampRef.current = Math.floor(Date.now() / 1000); // Optimistic processing — set BEFORE fetch const expectedState: Service["state"] = action === "stop" || action === "remove" ? "exited" : action === "start" || action === "restart" || action === "rebuild" ? "running" : service.state; const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0; onAction(service.uid, expectedState, minDuration); setInitialLogs([]); clearLogLines(); try { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(`/api/containers/${service.id}/${action}`, { method: "POST", headers }); const data = await res.json(); if (res.ok) { setActionResult({ type: "success", message: `${action} successful` }); if (action === "remove") { setTimeout(() => handleClose(), 1000); } } else { clearProcessing(service.uid); setActionResult({ type: "error", message: data.error || `Failed to ${action}` }); } } catch { clearProcessing(service.uid); setActionResult({ type: "error", message: `Failed to ${action}` }); } finally { setActionLoading(null); setTimeout(() => setActionResult(null), 3000); } }, [service.id, service.uid, token, onAction, clearProcessing]); const runExec = useCallback(async () => { if (!execCmd.trim()) return; setExecLoading(true); setExecResult(null); setExecError(null); try { const headers: Record = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(`/api/containers/${service.id}/exec`, { method: "POST", headers, body: JSON.stringify({ cmd: execCmd }), }); const data = await res.json(); if (res.ok && data.ok) { setExecResult({ output: data.output, exitCode: data.exitCode }); } else { setExecError(data.error || "Exec failed"); } } catch { setExecError("Network error"); } finally { setExecLoading(false); } }, [execCmd, service.id, token]); // Re-subscribe logs when exiting processing state useEffect(() => { const wasProcessing = prevProcessingRef.current; prevProcessingRef.current = isProcessing; if (wasProcessing && !isProcessing) { // Service just finished processing — re-fetch logs (only new ones since the action) setInitialLogs([]); clearLogLines(); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; const since = actionTimestampRef.current; const sinceParam = since ? `&since=${since}` : ""; fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers }) .then((r) => r.ok ? r.json() : []) .then((lines: LogLine[]) => setInitialLogs(lines)) .catch(() => {}); sendMessage({ type: "subscribe_logs", container: service.id }); } }, [isProcessing, service.state, service.id, token, sendMessage, clearLogLines]); // Slide-in animation useEffect(() => { requestAnimationFrame(() => setVisible(true)); }, []); // React to external close (pane click) useEffect(() => { if (closing) setVisible(false); }, [closing]); // Slide-out + zoom-out in parallel const handleClose = useCallback(() => { setVisible(false); onClose(); }, [onClose]); // Fetch initial logs + subscribe useEffect(() => { setInitialLogs([]); setLoading(true); clearLogLines(); initialScrollDone.current = false; const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; const since = getLogsSince(service.uid); const sinceParam = since ? `&since=${since}` : ""; fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers }) .then((r) => r.ok ? r.json() : []) .then((lines: LogLine[]) => { setInitialLogs(lines); setLoading(false); }) .catch(() => setLoading(false)); sendMessage({ type: "subscribe_logs", container: service.id }); subscribedRef.current = service.id; return () => { if (subscribedRef.current) { sendMessage({ type: "unsubscribe_logs" }); subscribedRef.current = null; } }; }, [service.id, token, sendMessage, clearLogLines]); // Auto-scroll const initialScrollDone = useRef(false); useEffect(() => { if (!autoScroll || !scrollRef.current) return; programmaticScroll.current = true; // Instant scroll until initial logs are loaded, then smooth if (!initialScrollDone.current && initialLogs.length > 0) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; initialScrollDone.current = true; } else if (initialScrollDone.current) { scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); } }, [initialLogs, logLines, autoScroll]); const programmaticScroll = useRef(false); const manualPause = useRef(false); const handleScroll = useCallback(() => { if (!scrollRef.current) return; if (programmaticScroll.current) { programmaticScroll.current = false; return; } const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const atBottom = scrollHeight - scrollTop - clientHeight < 5; if (manualPause.current) { // Only clear manual pause when user scrolls exactly to bottom if (atBottom) { manualPause.current = false; setAutoScroll(true); } return; } setAutoScroll((prev) => prev === atBottom ? prev : atBottom); }, []); // Docker events as special log lines const eventLogLines = useMemo(() => { return events .filter((e) => e.service === service.uid) .map((e): LogLine => ({ container: service.id, line: `[DOCKER] Container ${e.action}`, timestamp: new Date(e.time * 1000).toISOString(), stream: "stderr", })); }, [events, service.uid, service.id]); const allLines = useMemo(() => { const combined = [...initialLogs, ...logLines, ...eventLogLines]; combined.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); return combined; }, [initialLogs, logLines, eventLogLines]); const connectedSvcs = useMemo(() => { const connectedUids = new Set(); for (const c of connections) { if (c.from === service.uid) connectedUids.add(c.to); if (c.to === service.uid) connectedUids.add(c.from); } return services.filter((s) => connectedUids.has(s.uid)); }, [connections, services, service.uid]); const isCrashed = (service.state as string) === "crashed"; const stateColor = isProcessing ? "text-yellow-400" : isCrashed ? "text-orange-400" : service.state === "running" ? "text-emerald-400" : service.state === "exited" || service.state === "dead" ? "text-red-400" : "text-yellow-400"; const stateDot = isProcessing ? "bg-yellow-400 animate-pulse" : isCrashed ? "bg-orange-400" : service.state === "running" ? "bg-emerald-400" : service.state === "exited" || service.state === "dead" ? "bg-red-400" : "bg-yellow-400"; return (
{/* Header */}
{service.name} {service.ports.length > 0 && service.state === "running" && ( :{service.ports[0].host} )} {isProcessing ? `processing... ${elapsed}s` : isCrashed ? <>crashed (exit {service.exit_code}{service.oom_killed ? ", OOM" : ""}) : service.state}
{/* Action buttons */} {isProcessing ? (
Processing...
) : ( <> {service.compose_file && ( )} {service.state === "running" ? ( <> ) : ( <> )} )}
{/* Confirmation dialog */} {confirmAction && (
{confirmAction === "stop" ? "Stop this container? This will interrupt the service." : confirmAction === "restart" ? "Restart this container? This will briefly interrupt the service." : confirmAction === "remove" ? "Remove this container? This will stop and delete it." : "Rebuild this container? This will rebuild the image and recreate the container."}
)} {/* Tabs */}
{TABS.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id; return ( ); })}
{/* Tab content + Logs below */}
{/* Tab content area */}
{/* Info tab */} {activeTab === "info" && (
{/* Crash banner */} {isCrashed && (
Container crashed
Exit code: {service.exit_code} {service.oom_killed && OOM Killed} {service.restart_count > 0 && Restarted {service.restart_count} times}
Check the logs below for details
)} {service.status && ( )} {service.compose_file && ( )} {service.compose_file && (
Env File Only files starting with .env are detected {envFileEditing ? (
) : (
{envFiles[service.compose_file!] || "Auto"}
)}
)} {service.ports.length > 0 && (
Ports
{service.ports.map((p, i) => ( {p.host} → {p.container} ))}
)} {service.networks.length > 0 && (
Networks
{service.networks.map((n, i) => ( {n} {service.network_ips?.[n] && ( {service.network_ips[n]} )} ))}
)} {/* Connected services */} {connectedSvcs.length > 0 && (
Connected to
{connectedSvcs.map((s) => { const dotColor = s.state === "running" ? "bg-emerald-400" : s.state === "exited" || s.state === "dead" ? "bg-red-400" : "bg-yellow-400"; return ( {s.name} ); })}
)}
)} {/* Config tab */} {activeTab === "config" && (
{/* Restart policy */} {service.restart_policy && ( )} {/* Resource limits */}
Resource Limits
Memory Limit {service.memory_limit > 0 ? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB` : "Unlimited"}
CPU Quota {service.cpu_quota > 0 ? `${(service.cpu_quota / 1000).toFixed(0)}%` : "Unlimited"}
{/* Health check */}
Health Check {service.health_status ? (
{service.health_status} {service.health_log.length > 0 && (
Recent checks {service.health_log.map((entry, i) => (
{entry}
))}
)}
) : ( Not configured )}
)} {/* Env tab */} {activeTab === "env" && (() => { const filteredEnv = (service.env || []).filter((entry) => { const key = entry.split("=")[0]; return !SYSTEM_ENV_KEYS.has(key); }); return (
{filteredEnv.length} variables
{filteredEnv.length > 0 ? (
{filteredEnv.map((entry, i) => { const eqIdx = entry.indexOf("="); const key = eqIdx >= 0 ? entry.slice(0, eqIdx) : entry; const val = eqIdx >= 0 ? entry.slice(eqIdx + 1) : ""; return (
{(() => { const isVisible = envVisibleAll || envVisibleSet.has(i); return ( <> {key} = {isVisible ? val : "••••••••"} ); })()}
); })}
) : (
No environment variables available
)}
); })()} {/* Stats tab */} {activeTab === "stats" && (
{stats ? ( <>
80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} /> 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
{/* CPU bar */}
CPU Usage {stats.cpu.toFixed(1)}%
80 ? "bg-red-500" : stats.cpu > 50 ? "bg-yellow-500" : "bg-emerald-500"}`} style={{ width: `${Math.min(stats.cpu, 100)}%` }} />
{/* Memory bar */}
Memory Usage {stats.mem_mb.toFixed(0)} MB ({stats.mem_percent.toFixed(1)}%)
80 ? "bg-red-500" : stats.mem_percent > 50 ? "bg-yellow-500" : "bg-emerald-500"}`} style={{ width: `${Math.min(stats.mem_percent, 100)}%` }} />
) : (
No stats available
)}
)}
{/* Logs section — always visible at bottom */}
{/* Expand/collapse divider */}
Logs {service.state === "running" && subscribedRef.current && ( )}
{service.state === "running" && ( )}
{/* Exec panel — below logs header */} {execOpen && (
setExecCmd(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && execCmd.trim() && !execLoading) { e.preventDefault(); runExec(); } }} placeholder="e.g. python manage.py migrate" className="flex-1 bg-slate-900 border border-slate-600 rounded px-2.5 py-1.5 text-xs font-mono text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-purple-500" autoFocus />
{execError && (
{execError}
)} {execResult && (
Exit code: {execResult.exitCode}
{execResult.output || "(no output)"}
)}
)}
{loading && (
Loading logs...
)} {!loading && allLines.length === 0 && (
No logs available
)} {allLines.map((l, i) => (
{l.timestamp && ( {formatTimestamp(l.timestamp)} )} {l.line}
))}
{/* Logs fullscreen modal */} {logsModal && (
{service.name} logs {service.state === "running" && subscribedRef.current && ( )}
{allLines.length > 500 && (
{allLines.length - 500} lines hidden
)} {(allLines.length > 500 ? allLines.slice(-500) : allLines).map((l, i) => (
{l.timestamp && ( {formatTimestamp(l.timestamp)} )} {l.line}
))}
)}
); } const ERROR_PATTERN = /\b(error|fatal|critical|exception|traceback|panic|failed|segfault)\b/i; const WARN_PATTERN = /\b(warn|warning)\b/i; function logLineColor(l: LogLine): string { if (l.line.startsWith("[DOCKER]")) return "text-cyan-400 font-semibold"; if (ERROR_PATTERN.test(l.line)) return "text-red-400"; if (WARN_PATTERN.test(l.line)) return "text-yellow-400"; return "text-slate-400"; } function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label} {value}
); } function StatCard({ label, value, extra, color }: { label: string; value: string; extra?: string; color: string }) { return (
{label} {value} {extra && {extra}}
); } function formatTimestamp(ts: string): string { try { const d = new Date(ts); return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } catch { return ts.slice(11, 19); } }