This commit is contained in:
RGJorge
2026-05-01 06:12:24 +00:00
parent c6da792e4b
commit f055489d81
5 changed files with 686 additions and 63 deletions
+91 -61
View File
@@ -18,7 +18,7 @@ import { useDocker } from "./hooks/useDocker";
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
import { ParticleEngine } from "./engine/particles";
import { ParticleOverlay } from "./components/ParticleOverlay";
import { LogPanel } from "./panels/LogPanel";
import { DetailPanel } from "./panels/DetailPanel";
import { LoginScreen } from "./components/LoginScreen";
import { OffsetEdge } from "./components/OffsetEdge";
import { HeaderBar } from "./components/HeaderBar";
@@ -83,8 +83,10 @@ function Dashboard({ token }: { token: string }) {
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [logPanelService, setLogPanelService] = useState<Service | null>(null);
const [detailService, setDetailService] = useState<Service | null>(null);
const reactFlowRef = useRef<any>(null);
const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null);
const isDragging = useRef(false);
const NODE_W = NODE_WIDTH;
const NODE_H = NODE_HEIGHT;
@@ -92,14 +94,21 @@ function Dashboard({ token }: { token: string }) {
const MIN_X = G_PAD;
const MIN_Y = GROUP_HEADER + G_PAD;
// Fit view when log panel opens/closes
useEffect(() => {
if (reactFlowRef.current) {
setTimeout(() => {
reactFlowRef.current?.fitView({ padding: 0.3, duration: 300 });
}, 50);
// Close detail panel and restore viewport (with slide-out delay)
const [panelClosing, setPanelClosing] = useState(false);
const closeDetail = useCallback(() => {
if (panelClosing) return;
setPanelClosing(true);
if (prevViewport.current && reactFlowRef.current) {
reactFlowRef.current.setViewport(prevViewport.current, { duration: 500 });
prevViewport.current = null;
}
}, [logPanelService]);
setSelectedNode(null);
setTimeout(() => {
setDetailService(null);
setPanelClosing(false);
}, 300);
}, [panelClosing]);
// Load saved positions
useEffect(() => {
@@ -422,44 +431,32 @@ function Dashboard({ token }: { token: string }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredServices, statsVersion]);
// Edge/node highlighting
const connectedNodeIds = useMemo(() => {
if (!selectedNode) return null;
const ids = new Set<string>([selectedNode]);
for (const e of edges) {
if (e.source === selectedNode) ids.add(e.target);
if (e.target === selectedNode) ids.add(e.source);
}
return ids;
}, [selectedNode, edges]);
const styledEdges = useMemo(() => {
if (!selectedNode) return edges;
return edges.map((e) => {
const isConnected = e.source === selectedNode || e.target === selectedNode;
return {
...e,
style: { ...e.style, opacity: isConnected ? 1 : 0.08, strokeWidth: isConnected ? 2.5 : 1 },
};
});
}, [edges, selectedNode]);
const styledNodes = useMemo(() => {
if (!connectedNodeIds) return nodes;
// Dim nodes/edges when detail panel is open
const dimmedNodes = useMemo(() => {
if (!selectedNode) return nodes;
return nodes.map((n) => {
if (n.type !== "service") return n;
const isConnected = connectedNodeIds.has(n.id);
const isSelected = n.id === selectedNode;
return {
...n,
style: { ...n.style, opacity: isConnected ? 1 : 0.3 },
data: { ...n.data, highlighted: isSelected || isConnected },
style: { ...n.style, opacity: isSelected ? 1 : 0.25, transition: "opacity 0.4s ease" },
data: { ...n.data, activeHandles: [] },
};
});
}, [nodes, connectedNodeIds, selectedNode]);
}, [nodes, selectedNode]);
const dimmedEdges = useMemo(() => {
if (!selectedNode) return edges;
return edges.map((e) => ({
...e,
style: { ...e.style, opacity: 0.1, transition: "opacity 0.4s ease" },
label: undefined,
labelStyle: { opacity: 0 },
}));
}, [edges, selectedNode]);
return (
<div className="h-screen w-screen bg-slate-950 flex flex-col">
<div className="h-screen w-screen bg-slate-900 flex flex-col">
<HeaderBar
services={services}
filteredServices={filteredServices}
@@ -475,25 +472,54 @@ function Dashboard({ token }: { token: string }) {
onSimulate={handleSimulate}
/>
{/* Canvas */}
<div className="flex-1 min-h-0">
{/* Canvas — inset */}
<div className="flex-1 min-h-0 relative m-2 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)]">
<ReactFlow
onInit={(instance) => { reactFlowRef.current = instance; }}
nodes={styledNodes}
edges={styledEdges}
nodes={dimmedNodes}
edges={dimmedEdges}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStart={() => { isDragging.current = true; }}
onNodeDragStop={() => { isDragging.current = false; }}
onNodeClick={(_e, node) => {
if (node.type === "service") {
setSelectedNode(node.id);
const svc = filteredServices.find((s) => s.uid === node.id);
if (svc) setLogPanelService(svc);
} else {
setSelectedNode(null);
if (isDragging.current) return;
if (node.type !== "service") return;
const svc = filteredServices.find((s) => s.uid === node.id);
if (!svc) return;
// Save current viewport before zooming
if (reactFlowRef.current && !prevViewport.current) {
prevViewport.current = reactFlowRef.current.getViewport();
}
// Compute absolute position (own position + parent group position)
let absX = node.position.x;
let absY = node.position.y;
if (node.parentId) {
const parent = nodes.find((n) => n.id === node.parentId);
if (parent) {
absX += parent.position.x;
absY += parent.position.y;
}
}
// Zoom to 1 and position node at ~75% from left (panel opens on left)
const vw = window.innerWidth;
const vh = window.innerHeight - 48; // subtract header height
const zoom = 1;
const targetX = vw * 0.75 - (absX + NODE_W / 2) * zoom;
const targetY = vh * 0.5 - (absY + NODE_H / 2) * zoom;
reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 500 });
setSelectedNode(node.id);
setDetailService(svc);
}}
onPaneClick={() => {
if (detailService) closeDetail();
}}
onNodeDragStop={() => setSelectedNode(null)}
onPaneClick={() => { setSelectedNode(null); setLogPanelService(null); }}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
@@ -520,18 +546,22 @@ function Dashboard({ token }: { token: string }) {
style={{ background: "#0f172a" }}
/>
</ReactFlow>
</div>
{logPanelService && (
<LogPanel
service={logPanelService}
logLines={logLines}
token={token}
onClose={() => { setLogPanelService(null); setSelectedNode(null); }}
sendMessage={sendMessage}
clearLogLines={clearLogLines}
/>
)}
{detailService && (
<DetailPanel
service={detailService}
stats={stats.get(detailService.uid)}
logLines={logLines}
token={token}
closing={panelClosing}
onClose={closeDetail}
sendMessage={sendMessage}
clearLogLines={clearLogLines}
connections={filteredConnections}
services={filteredServices}
/>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function HeaderBar({
}, []);
return (
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
<div className="flex items-center justify-between px-5 py-3.5 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
<img
+554
View File
@@ -0,0 +1,554 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { X, Pause, Play, Terminal, Network, Globe, Info, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check } 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;
sendMessage: (msg: WSMessage) => void;
clearLogLines: () => void;
connections: Connection[];
services: Service[];
}
export function DetailPanel({ service, stats, logLines, token, closing, onClose, sendMessage, clearLogLines, connections, services }: 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);
// Slide-in animation
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
// React to external close (pane click)
useEffect(() => {
if (closing) setVisible(false);
}, [closing]);
// Slide-out then unmount
const handleClose = useCallback(() => {
setVisible(false);
setTimeout(() => onClose(), 300);
}, [onClose]);
// Fetch initial logs + subscribe
useEffect(() => {
setInitialLogs([]);
setLoading(true);
clearLogLines();
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/logs/${service.id}?tail=200`, { 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
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [initialLogs, logLines, autoScroll]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const atBottom = scrollHeight - scrollTop - clientHeight < 40;
setAutoScroll(atBottom);
}, []);
const allLines = [...initialLogs, ...logLines.filter((l) => l.container === service.id)];
const stateColor =
service.state === "running" ? "text-emerald-400" :
service.state === "exited" || service.state === "dead" ? "text-red-400" :
"text-yellow-400";
const stateDot =
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-300 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}`}>{service.state}</span>
</div>
<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>
{/* 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">
{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)}
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="flex items-center gap-1 text-[10px] text-cyan-400">
<span className="w-1 h-1 rounded-full bg-cyan-400 animate-pulse" />
live
</span>
)}
</div>
<button
onClick={() => setAutoScroll((v) => !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>
</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-wrap break-all ${l.stream === "stderr" ? "text-red-400" : "text-slate-300"}`}>
{l.line}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
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);
}
}
+33 -1
View File
@@ -17,9 +17,34 @@ export { docker };
export async function discoverServices(all: boolean, projects: string[]): Promise<Service[]> {
const containers = await docker.listContainers({ all: true });
let services: Service[] = containers.map((c) => {
// Inspect containers in parallel to get detailed info
const inspections = await Promise.all(
containers.map((c) =>
docker.getContainer(c.Id).inspect().catch(() => null)
)
);
let services: Service[] = containers.map((c, i) => {
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
const project = c.Labels["com.docker.compose.project"] || "standalone";
const info = inspections[i] as any;
// Extract network IPs
const networkIps: Record<string, string> = {};
const nets = info?.NetworkSettings?.Networks || {};
for (const [netName, netInfo] of Object.entries(nets)) {
const ip = (netInfo as any)?.IPAddress;
if (ip) networkIps[netName] = ip;
}
// Health check
const healthState = info?.State?.Health;
const healthStatus = healthState?.Status || "";
const healthLog = (healthState?.Log || [])
.slice(-5)
.map((entry: any) => `[${entry.ExitCode}] ${entry.Output?.trim() || ""}`)
.filter((s: string) => s.length > 4);
return {
id: c.Id.slice(0, 12),
uid: `${project}/${name}`,
@@ -34,8 +59,15 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
])
).values()],
networks: Object.keys(c.NetworkSettings?.Networks || {}),
network_ips: networkIps,
project,
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
env: (info?.Config?.Env || []) as string[],
restart_policy: info?.HostConfig?.RestartPolicy?.Name || "",
memory_limit: info?.HostConfig?.Memory || 0,
cpu_quota: info?.HostConfig?.CpuQuota || 0,
health_status: healthStatus,
health_log: healthLog,
};
});
+7
View File
@@ -7,8 +7,15 @@ export interface Service {
status: string;
ports: { host: number; container: number }[];
networks: string[];
network_ips: Record<string, string>;
project: string;
compose_file: string;
env: string[];
restart_policy: string;
memory_limit: number;
cpu_quota: number;
health_status: string;
health_log: string[];
}
export interface Connection {