import { useEffect, useRef, useState, useCallback } from "react"; import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2 } from "lucide-react"; import type { Service, Stats, LogLine, WSMessage, Connection } 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 Info }[] = [ { id: "info", label: "Info", icon: Info }, { 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; sendMessage: (msg: WSMessage) => void; clearLogLines: () => void; connections: Connection[]; services: Service[]; getLogsSince: (uid: string) => number | undefined; } export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince }: 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(false); const modalScrollRef = useRef(null); // Scroll modal to bottom when opened useEffect(() => { if (logsModal && modalScrollRef.current) { modalScrollRef.current.scrollTop = modalScrollRef.current.scrollHeight; } }, [logsModal]); 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); 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` }); setInitialLogs([]); clearLogLines(); // Set processing — pass expected state so we wait for server to confirm const expectedState: Service["state"] = action === "stop" || action === "remove" ? "exited" : action === "start" || action === "restart" || action === "rebuild" ? "running" : service.state; // restart/rebuild go through stop→start cycle, need minDuration to avoid clearing on intermediate states const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0; onAction(service.uid, expectedState, minDuration); if (action === "remove") { setTimeout(() => handleClose(), 1000); } } else { setActionResult({ type: "error", message: data.error || `Failed to ${action}` }); } } catch { setActionResult({ type: "error", message: `Failed to ${action}` }); } finally { setActionLoading(null); setTimeout(() => setActionResult(null), 3000); } }, [service.id, service.uid, token, onAction]); // 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); }, []); const allLines = [...initialLogs, ...logLines.filter((l) => l.container === service.id)]; 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} {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.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 */} {(() => { 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); } const connectedSvcs = services.filter((s) => connectedUids.has(s.uid)); if (connectedSvcs.length === 0) return null; return (
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 && ( )}
{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.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 (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); } }