mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
882 lines
41 KiB
TypeScript
882 lines
41 KiB
TypeScript
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<LogLine[]>([]);
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const subscribedRef = useRef<string | null>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<Tab>("info");
|
|
const [logsExpanded, setLogsExpanded] = useState(false);
|
|
const [envVisibleAll, setEnvVisibleAll] = useState(false);
|
|
const [envVisibleSet, setEnvVisibleSet] = useState<Set<number>>(new Set());
|
|
const [copiedEnvIdx, setCopiedEnvIdx] = useState<number | null>(null);
|
|
const [logsModal, setLogsModal] = useState(false);
|
|
const modalScrollRef = useRef<HTMLDivElement>(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<number | null>(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<string | null>(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<string, string> = {};
|
|
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<string, string> = {};
|
|
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<string, string> = {};
|
|
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 (
|
|
<div
|
|
className={`absolute top-0 left-0 bottom-0 w-[900px] bg-slate-900/95 backdrop-blur-sm border-r border-slate-700/60 flex flex-col z-50 rounded-l-xl transition-transform duration-[400ms] ease-out ${visible ? "translate-x-0" : "-translate-x-full"}`}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<span className={`w-2 h-2 rounded-full ${stateDot}`} />
|
|
<span className="text-sm font-semibold text-white truncate">{service.name}</span>
|
|
<span className={`text-xs font-mono ${stateColor} flex items-center gap-1`}>
|
|
{isProcessing ? `processing... ${elapsed}s` :
|
|
isCrashed ? <><AlertTriangle size={11} />crashed (exit {service.exit_code}{service.oom_killed ? ", OOM" : ""})</> :
|
|
service.state}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{/* Action buttons */}
|
|
{isProcessing ? (
|
|
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-yellow-400">
|
|
<Loader2 size={12} className="animate-spin" />
|
|
Processing...
|
|
</div>
|
|
) : (
|
|
<>
|
|
{service.compose_file && (
|
|
<button
|
|
onClick={() => setConfirmAction("rebuild")}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
|
|
title="Rebuild"
|
|
>
|
|
{actionLoading === "rebuild" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />}
|
|
Rebuild
|
|
</button>
|
|
)}
|
|
{service.state === "running" ? (
|
|
<>
|
|
<button
|
|
onClick={() => setConfirmAction("restart")}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-yellow-400 hover:bg-yellow-400/10 transition-colors disabled:opacity-40"
|
|
title="Restart"
|
|
>
|
|
{actionLoading === "restart" ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
|
|
Restart
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmAction("stop")}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
|
|
title="Stop"
|
|
>
|
|
{actionLoading === "stop" ? <Loader2 size={12} className="animate-spin" /> : <Square size={12} />}
|
|
Stop
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => setConfirmAction("remove")}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
|
|
title="Remove"
|
|
>
|
|
{actionLoading === "remove" ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
|
Remove
|
|
</button>
|
|
<button
|
|
onClick={() => executeAction("start")}
|
|
disabled={!!actionLoading}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors disabled:opacity-40 ${
|
|
isCrashed ? "text-orange-400 hover:bg-orange-400/10" : "text-emerald-400 hover:bg-emerald-400/10"
|
|
}`}
|
|
title={isCrashed ? "Retry start" : "Start"}
|
|
>
|
|
{actionLoading === "start" ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
|
{isCrashed ? "Retry" : "Start"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="w-px h-4 bg-slate-700 mx-1" />
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
|
title="Close"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Confirmation dialog */}
|
|
{confirmAction && (
|
|
<div className="px-4 py-2.5 bg-slate-800/90 border-b border-slate-700/60 flex items-center gap-3 shrink-0">
|
|
<AlertTriangle size={14} className={`shrink-0 ${
|
|
confirmAction === "stop" || confirmAction === "remove" ? "text-red-400" :
|
|
confirmAction === "rebuild" ? "text-cyan-400" :
|
|
"text-yellow-400"
|
|
}`} />
|
|
<span className="text-xs text-slate-300 flex-1">
|
|
{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."}
|
|
</span>
|
|
<button
|
|
onClick={() => executeAction(confirmAction)}
|
|
className={`px-3 py-1 rounded text-[11px] font-medium text-white transition-colors ${
|
|
confirmAction === "stop" || confirmAction === "remove" ? "bg-red-700 hover:bg-red-600" :
|
|
confirmAction === "rebuild" ? "bg-cyan-700 hover:bg-cyan-600" :
|
|
"bg-yellow-700 hover:bg-yellow-600"
|
|
}`}
|
|
>
|
|
{confirmAction === "stop" ? "Stop" : confirmAction === "restart" ? "Restart" : confirmAction === "remove" ? "Remove" : "Rebuild"}
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmAction(null)}
|
|
className="px-3 py-1 rounded text-[11px] font-medium text-slate-400 hover:text-slate-200 bg-slate-700 hover:bg-slate-600 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center border-b border-slate-800 shrink-0">
|
|
{TABS.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const isActive = activeTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => { setActiveTab(tab.id); setLogsExpanded(false); }}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium relative transition-colors ${
|
|
isActive ? "text-cyan-400" : "text-slate-500 hover:text-slate-300"
|
|
}`}
|
|
>
|
|
<Icon size={12} />
|
|
{tab.label}
|
|
<span className={`absolute bottom-0 left-0 right-0 h-px bg-cyan-400 transition-transform duration-300 ease-out origin-center ${isActive ? "scale-x-100" : "scale-x-0"}`} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Tab content + Logs below */}
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
{/* Tab content area */}
|
|
<div style={{ flexBasis: logsExpanded ? "0px" : "75%", flexShrink: 0, transition: "flex-basis 300ms ease-in-out, padding 300ms ease-in-out" }} className={`overflow-hidden ${logsExpanded ? "" : "overflow-y-auto"}`}>
|
|
{/* Info tab */}
|
|
{activeTab === "info" && (
|
|
<div className="px-4 py-3 space-y-3">
|
|
{/* Crash banner */}
|
|
{isCrashed && (
|
|
<div className="flex items-start gap-2.5 bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5">
|
|
<AlertTriangle size={16} className="text-orange-400 shrink-0 mt-0.5" />
|
|
<div className="text-xs space-y-1">
|
|
<div className="font-semibold text-orange-300">Container crashed</div>
|
|
<div className="text-slate-400">
|
|
Exit code: <span className="text-orange-300 font-mono">{service.exit_code}</span>
|
|
{service.oom_killed && <span className="ml-2 text-red-400 font-semibold">OOM Killed</span>}
|
|
{service.restart_count > 0 && <span className="ml-2">Restarted <span className="text-orange-300 font-mono">{service.restart_count}</span> times</span>}
|
|
</div>
|
|
<div className="text-slate-500">Check the logs below for details</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{service.status && (
|
|
<DetailRow label="Status" value={service.status} />
|
|
)}
|
|
<DetailRow label="Image" value={service.image} mono />
|
|
<DetailRow label="Container" value={service.id.slice(0, 12)} mono />
|
|
<DetailRow label="Project" value={service.project} />
|
|
{service.compose_file && (
|
|
<DetailRow label="Compose" value={service.compose_file} mono />
|
|
)}
|
|
|
|
{service.ports.length > 0 && (
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Ports</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{service.ports.map((p, i) => (
|
|
<span key={i} className="inline-flex items-center gap-1.5 text-sm font-mono bg-slate-800/80 text-cyan-300 px-2.5 py-1 rounded">
|
|
<Globe size={13} className="text-slate-500" />
|
|
{p.host} → {p.container}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{service.networks.length > 0 && (
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Networks</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{service.networks.map((n, i) => (
|
|
<span key={i} className="inline-flex items-center gap-1.5 text-sm font-mono bg-slate-800/80 text-purple-300 px-2.5 py-1 rounded">
|
|
<Network size={13} className="text-slate-500" />
|
|
{n}
|
|
{service.network_ips?.[n] && (
|
|
<span className="text-slate-500 ml-1">{service.network_ips[n]}</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connected services */}
|
|
{(() => {
|
|
const connectedUids = new Set<string>();
|
|
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 (
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Connected to</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{connectedSvcs.map((s) => {
|
|
const dotColor = s.state === "running" ? "bg-emerald-400" : s.state === "exited" || s.state === "dead" ? "bg-red-400" : "bg-yellow-400";
|
|
return (
|
|
<span key={s.uid} className="inline-flex items-center gap-1.5 text-sm bg-slate-800/80 text-slate-300 px-2.5 py-1 rounded">
|
|
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
|
|
{s.name}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Config tab */}
|
|
{activeTab === "config" && (
|
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
|
{/* Restart policy */}
|
|
{service.restart_policy && (
|
|
<DetailRow label="Restart Policy" value={service.restart_policy} />
|
|
)}
|
|
|
|
{/* Resource limits */}
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Resource Limits</span>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="bg-slate-800/80 rounded px-3 py-2">
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block">Memory Limit</span>
|
|
<span className="text-xs font-mono text-slate-200">
|
|
{service.memory_limit > 0
|
|
? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB`
|
|
: "Unlimited"}
|
|
</span>
|
|
</div>
|
|
<div className="bg-slate-800/80 rounded px-3 py-2">
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block">CPU Quota</span>
|
|
<span className="text-xs font-mono text-slate-200">
|
|
{service.cpu_quota > 0
|
|
? `${(service.cpu_quota / 1000).toFixed(0)}%`
|
|
: "Unlimited"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Health check */}
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Health Check</span>
|
|
{service.health_status ? (
|
|
<div className="space-y-2">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs font-mono px-2 py-0.5 rounded ${
|
|
service.health_status === "healthy" ? "bg-emerald-900/40 text-emerald-400" :
|
|
service.health_status === "unhealthy" ? "bg-red-900/40 text-red-400" :
|
|
"bg-yellow-900/40 text-yellow-400"
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
|
service.health_status === "healthy" ? "bg-emerald-400" :
|
|
service.health_status === "unhealthy" ? "bg-red-400" :
|
|
"bg-yellow-400"
|
|
}`} />
|
|
{service.health_status}
|
|
</span>
|
|
{service.health_log.length > 0 && (
|
|
<div className="bg-slate-800/60 rounded p-2 space-y-0.5">
|
|
<span className="text-[10px] text-slate-500 block mb-1">Recent checks</span>
|
|
{service.health_log.map((entry, i) => (
|
|
<div key={i} className="text-[11px] font-mono text-slate-400 break-all">{entry}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-slate-500">Not configured</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Env tab */}
|
|
{activeTab === "env" && (() => {
|
|
const filteredEnv = (service.env || []).filter((entry) => {
|
|
const key = entry.split("=")[0];
|
|
return !SYSTEM_ENV_KEYS.has(key);
|
|
});
|
|
return (
|
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500">{filteredEnv.length} variables</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => {
|
|
const text = filteredEnv.join("\n");
|
|
try {
|
|
navigator.clipboard.writeText(text);
|
|
} catch {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
}
|
|
setCopiedEnvIdx(-1);
|
|
setTimeout(() => setCopiedEnvIdx(null), 1500);
|
|
}}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
|
>
|
|
{copiedEnvIdx === -1 ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
|
|
{copiedEnvIdx === -1 ? "Copied!" : "Copy all"}
|
|
</button>
|
|
<button
|
|
onClick={() => { setEnvVisibleAll((v) => !v); setEnvVisibleSet(new Set()); }}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
|
>
|
|
{envVisibleAll ? <EyeOff size={11} /> : <Eye size={11} />}
|
|
{envVisibleAll ? "Hide all" : "Show all"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{filteredEnv.length > 0 ? (
|
|
<div className="space-y-0.5">
|
|
{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 (
|
|
<div key={i} className="group flex items-center gap-0 font-mono text-xs py-1.5 hover:bg-slate-800/40 rounded px-1">
|
|
{(() => {
|
|
const isVisible = envVisibleAll || envVisibleSet.has(i);
|
|
return (
|
|
<>
|
|
<span className="text-cyan-400 shrink-0">{key}</span>
|
|
<span className="text-slate-600 mx-1">=</span>
|
|
<span className={`break-all flex-1 ${isVisible ? "text-slate-300" : "text-slate-600 select-none"}`}>
|
|
{isVisible ? val : "••••••••"}
|
|
</span>
|
|
<button
|
|
onClick={() => setEnvVisibleSet((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(i)) next.delete(i); else next.add(i);
|
|
return next;
|
|
})}
|
|
className="shrink-0 ml-2 p-1 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-200 transition-opacity"
|
|
title={isVisible ? "Hide" : "Show"}
|
|
>
|
|
{isVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
try {
|
|
navigator.clipboard.writeText(entry);
|
|
} catch {
|
|
// Fallback for non-HTTPS
|
|
const ta = document.createElement("textarea");
|
|
ta.value = entry;
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
}
|
|
setCopiedEnvIdx(i);
|
|
setTimeout(() => setCopiedEnvIdx(null), 1500);
|
|
}}
|
|
className="shrink-0 ml-1 p-1 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-200 transition-opacity"
|
|
title="Copy"
|
|
>
|
|
{copiedEnvIdx === i ? <Check size={15} className="text-emerald-400" /> : <Copy size={15} />}
|
|
</button>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-slate-500 text-sm text-center py-8">No environment variables available</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Stats tab */}
|
|
{activeTab === "stats" && (
|
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
|
{stats ? (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<StatCard label="CPU" value={`${stats.cpu.toFixed(1)}%`} color={stats.cpu > 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} />
|
|
<StatCard label="Memory" value={`${stats.mem_mb.toFixed(0)} MB`} extra={`${stats.mem_percent.toFixed(1)}%`} color={stats.mem_percent > 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
|
|
</div>
|
|
|
|
{/* CPU bar */}
|
|
<div>
|
|
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
|
<span>CPU Usage</span>
|
|
<span>{stats.cpu.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${stats.cpu > 80 ? "bg-red-500" : stats.cpu > 50 ? "bg-yellow-500" : "bg-emerald-500"}`}
|
|
style={{ width: `${Math.min(stats.cpu, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Memory bar */}
|
|
<div>
|
|
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
|
<span>Memory Usage</span>
|
|
<span>{stats.mem_mb.toFixed(0)} MB ({stats.mem_percent.toFixed(1)}%)</span>
|
|
</div>
|
|
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${stats.mem_percent > 80 ? "bg-red-500" : stats.mem_percent > 50 ? "bg-yellow-500" : "bg-emerald-500"}`}
|
|
style={{ width: `${Math.min(stats.mem_percent, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-slate-500 text-sm text-center py-8">No stats available</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{/* Logs section — always visible at bottom */}
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
{/* Expand/collapse divider */}
|
|
<div className="relative shrink-0">
|
|
<div className="border-t border-slate-700/60" />
|
|
<div className="absolute inset-x-0 -top-3 flex justify-center">
|
|
<button
|
|
onClick={() => {
|
|
setLogsExpanded((v) => !v);
|
|
// Scroll to bottom during and after transition
|
|
const scrollToBottom = () => {
|
|
if (scrollRef.current) {
|
|
programmaticScroll.current = true;
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
};
|
|
scrollToBottom();
|
|
setTimeout(scrollToBottom, 50);
|
|
setTimeout(scrollToBottom, 150);
|
|
setTimeout(scrollToBottom, 300);
|
|
setTimeout(scrollToBottom, 350);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-0.5 rounded-full bg-slate-800 border border-slate-700/60 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors text-[10px] font-medium"
|
|
title={logsExpanded ? "Collapse logs" : "Expand logs"}
|
|
>
|
|
{logsExpanded ? <ChevronDown size={10} /> : <ChevronUp size={10} />}
|
|
{logsExpanded ? "Collapse" : "Expand"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<Terminal size={14} className="text-cyan-400" />
|
|
<span className="text-sm font-medium text-slate-300">Logs</span>
|
|
{service.state === "running" && subscribedRef.current && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => {
|
|
setAutoScroll((v) => {
|
|
if (v) manualPause.current = true;
|
|
else manualPause.current = false;
|
|
return !v;
|
|
});
|
|
}}
|
|
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
|
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll"}
|
|
>
|
|
{autoScroll ? <Pause size={12} /> : <Play size={12} />}
|
|
</button>
|
|
<button
|
|
onClick={() => setLogsModal(true)}
|
|
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
|
title="Open logs fullscreen"
|
|
>
|
|
<Maximize2 size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs leading-5 px-3 py-2"
|
|
>
|
|
{loading && (
|
|
<div className="text-slate-500 py-4 text-center">Loading logs...</div>
|
|
)}
|
|
{!loading && allLines.length === 0 && (
|
|
<div className="text-slate-500 py-4 text-center">No logs available</div>
|
|
)}
|
|
{allLines.map((l, i) => (
|
|
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
|
|
{l.timestamp && (
|
|
<span className="text-slate-600 shrink-0 select-none pr-2 whitespace-nowrap">
|
|
{formatTimestamp(l.timestamp)}
|
|
</span>
|
|
)}
|
|
<span className={`whitespace-pre truncate ${logLineColor(l)}`}>
|
|
{l.line}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logs fullscreen modal */}
|
|
{logsModal && (
|
|
<div className="fixed inset-0 z-[100] bg-slate-900 flex flex-col">
|
|
<div className="flex items-center justify-between px-6 py-3 border-b border-slate-800 shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<Terminal size={16} className="text-cyan-400" />
|
|
<span className="text-sm font-semibold text-white">{service.name}</span>
|
|
<span className="text-xs text-slate-500 font-mono">logs</span>
|
|
{service.state === "running" && subscribedRef.current && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => {
|
|
const text = allLines.map((l) => `${l.timestamp ? formatTimestamp(l.timestamp) + " " : ""}${l.line}`).join("\n");
|
|
try {
|
|
navigator.clipboard.writeText(text);
|
|
} catch {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
}
|
|
setCopiedEnvIdx(-99);
|
|
setTimeout(() => setCopiedEnvIdx(null), 1500);
|
|
}}
|
|
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
|
title="Copy all logs"
|
|
>
|
|
{copiedEnvIdx === -99 ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
|
</button>
|
|
<button
|
|
onClick={() => setLogsModal(false)}
|
|
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={modalScrollRef}
|
|
className="flex-1 overflow-y-auto overflow-x-auto font-mono text-xs leading-5 px-6 py-3"
|
|
>
|
|
{allLines.map((l, i) => (
|
|
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
|
|
{l.timestamp && (
|
|
<span className="text-slate-600 shrink-0 select-none pr-3 whitespace-nowrap">
|
|
{formatTimestamp(l.timestamp)}
|
|
</span>
|
|
)}
|
|
<span className={`whitespace-pre-wrap break-all ${logLineColor(l)}`}>
|
|
{l.line}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-0.5">{label}</span>
|
|
<span className={`text-sm text-slate-200 ${mono ? "font-mono" : ""} break-all`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, extra, color }: { label: string; value: string; extra?: string; color: string }) {
|
|
return (
|
|
<div className="bg-slate-800/80 rounded-lg px-4 py-3">
|
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{label}</span>
|
|
<span className={`text-xl font-mono font-semibold ${color}`}>{value}</span>
|
|
{extra && <span className="text-xs text-slate-500 ml-2">{extra}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|