mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.10
This commit is contained in:
+91
-61
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user