mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.1
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
SmoothStepEdge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type NodeChange,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Wifi, WifiOff, ChevronDown, Check, Lock, LogOut, Eye, EyeOff, Terminal, Database, Zap, Radio, Globe } from "lucide-react";
|
||||
|
||||
import { ServiceNode } from "./nodes/ServiceNode";
|
||||
import { GroupNode } from "./nodes/GroupNode";
|
||||
import { useDocker } from "./hooks/useDocker";
|
||||
import { buildLayout, computeEdges } from "./engine/layout";
|
||||
import { LogPanel } from "./panels/LogPanel";
|
||||
import type { Service } from "../shared/types";
|
||||
|
||||
function OffsetEdge(props: EdgeProps) {
|
||||
const offset = (props.data as any)?.offset ?? 0;
|
||||
return <SmoothStepEdge {...props} pathOptions={{ offset, borderRadius: 8 }} />;
|
||||
}
|
||||
|
||||
const nodeTypes = { service: ServiceNode, group: GroupNode };
|
||||
const edgeTypes = { offsetSmooth: OffsetEdge };
|
||||
|
||||
function loadFilter(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem("df:filter");
|
||||
if (raw) return new Set(JSON.parse(raw));
|
||||
} catch {}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("df:token") || "";
|
||||
}
|
||||
|
||||
function LoginScreen({ onAuth }: { onAuth: (token: string) => void }) {
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
|
||||
const hackerLog = (lines: string[], onDone: () => void) => {
|
||||
lines.forEach((line, i) => {
|
||||
setTimeout(() => {
|
||||
setLogLines((prev) => [...prev, line]);
|
||||
if (i === lines.length - 1) setTimeout(onDone, 400);
|
||||
}, i * 180);
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (connecting) return;
|
||||
setConnecting(true);
|
||||
setError("");
|
||||
setLogLines([]);
|
||||
|
||||
hackerLog([
|
||||
"$ dockerflow connect --auth",
|
||||
"> Establishing secure connection...",
|
||||
"> Validating AUTH_TOKEN...",
|
||||
], async () => {
|
||||
try {
|
||||
const res = await fetch("/api/health", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
hackerLog([
|
||||
"> Token accepted",
|
||||
"> Loading Docker socket...",
|
||||
"> Connection established!",
|
||||
], () => {
|
||||
localStorage.setItem("df:token", token);
|
||||
setConnected(true);
|
||||
setTimeout(() => onAuth(token), 800);
|
||||
});
|
||||
} else {
|
||||
hackerLog(["> ERROR: Invalid token", "> Connection refused"], () => {
|
||||
setError("Token invalido");
|
||||
setConnecting(false);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
hackerLog(["> ERROR: Connection failed"], () => {
|
||||
setError("No se pudo conectar");
|
||||
setConnecting(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-screen w-screen bg-slate-950 flex items-center justify-center transition-opacity duration-700 ${connected ? "opacity-0" : "opacity-100"}`}>
|
||||
<div className="flex flex-col items-center gap-6 w-80">
|
||||
{/* Logo + Title */}
|
||||
<img
|
||||
src="/alteonx-logo.png"
|
||||
alt="Alteonx"
|
||||
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
|
||||
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white tracking-wide">DockerFlow</h1>
|
||||
<span className="text-xs text-cyan-400 tracking-widest uppercase">AlteonX</span>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={submit} className={`flex flex-col gap-3 w-full transition-opacity duration-300 ${connecting ? "opacity-50 pointer-events-none" : ""}`}>
|
||||
<div className="relative">
|
||||
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={token}
|
||||
onChange={(e) => { setToken(e.target.value); setError(""); }}
|
||||
placeholder="AUTH_TOKEN"
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-sm text-white font-mono placeholder:text-slate-600 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
autoFocus
|
||||
disabled={connecting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <span className="text-red-400 text-xs font-mono">{error}</span>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={connecting || !token}
|
||||
className={`w-full flex items-center justify-center gap-2 text-sm font-medium py-2.5 rounded-lg transition-all duration-300 ${
|
||||
connecting
|
||||
? "bg-slate-800 text-slate-500 cursor-wait"
|
||||
: "bg-cyan-600 hover:bg-cyan-500 text-white hover:shadow-lg hover:shadow-cyan-500/20"
|
||||
}`}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{connecting ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Terminal log */}
|
||||
{logLines.length > 0 && (
|
||||
<div className="w-full bg-slate-900/80 border border-slate-800 rounded-lg p-3 font-mono text-[11px] space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{logLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${
|
||||
line.includes("ERROR") ? "text-red-400" :
|
||||
line.includes("accepted") || line.includes("established") ? "text-emerald-400" :
|
||||
line.startsWith("$") ? "text-cyan-400" : "text-slate-400"
|
||||
} animate-[fadeIn_0.15s_ease-out]`}
|
||||
>
|
||||
{line}
|
||||
{i === logLines.length - 1 && !connected && (
|
||||
<span className="inline-block w-1.5 h-3 bg-cyan-400 ml-1 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [needsAuth, setNeedsAuth] = useState<boolean | null>(null);
|
||||
|
||||
// Check if auth is required
|
||||
useEffect(() => {
|
||||
fetch("/api/health").then((r) => {
|
||||
if (r.ok) {
|
||||
setNeedsAuth(false);
|
||||
setAuthToken("");
|
||||
} else if (r.status === 401) {
|
||||
const saved = getToken();
|
||||
if (saved) {
|
||||
fetch("/api/health", { headers: { Authorization: `Bearer ${saved}` } }).then((r2) => {
|
||||
if (r2.ok) { setAuthToken(saved); setNeedsAuth(false); }
|
||||
else { localStorage.removeItem("df:token"); setNeedsAuth(true); }
|
||||
});
|
||||
} else {
|
||||
setNeedsAuth(true);
|
||||
}
|
||||
}
|
||||
}).catch(() => setNeedsAuth(false));
|
||||
}, []);
|
||||
|
||||
if (needsAuth === null) return <div className="h-screen w-screen bg-slate-950" />;
|
||||
if (needsAuth) return <LoginScreen onAuth={(t) => { setAuthToken(t); setNeedsAuth(false); }} />;
|
||||
|
||||
return <Dashboard token={authToken || ""} />;
|
||||
}
|
||||
|
||||
function Dashboard({ token }: { token: string }) {
|
||||
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines } = useDocker(token);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const initialLayoutDone = useRef(false);
|
||||
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
|
||||
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [logPanelService, setLogPanelService] = useState<Service | null>(null);
|
||||
const reactFlowRef = useRef<any>(null);
|
||||
|
||||
// Fit view when log panel opens/closes so graph adjusts to available space
|
||||
useEffect(() => {
|
||||
if (reactFlowRef.current) {
|
||||
// Small delay to let the DOM resize first
|
||||
setTimeout(() => {
|
||||
reactFlowRef.current?.fitView({ padding: 0.3, duration: 300 });
|
||||
}, 50);
|
||||
}
|
||||
}, [logPanelService]);
|
||||
|
||||
// Load saved positions from server on mount
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch("/api/positions", { headers })
|
||||
.then((r) => r.json())
|
||||
.then((data) => { savedPositions.current = data || {}; })
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// Save positions to server (debounced)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const savePositions = useCallback((nodes: Node[]) => {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
for (const n of nodes) {
|
||||
positions[n.id] = { x: n.position.x, y: n.position.y };
|
||||
}
|
||||
savedPositions.current = positions;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch("/api/positions", {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(positions),
|
||||
}).catch(() => {});
|
||||
}, 500);
|
||||
}, [token]);
|
||||
|
||||
// Resize groups and clamp child positions
|
||||
const NODE_W = 240;
|
||||
const NODE_H = 160;
|
||||
const G_PAD = 28;
|
||||
const G_HEADER = 44;
|
||||
const MIN_X = G_PAD;
|
||||
const MIN_Y = G_HEADER + G_PAD;
|
||||
|
||||
const handleNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
||||
onNodesChange(changes);
|
||||
|
||||
const hasPositionChange = changes.some((c) => c.type === "position");
|
||||
if (!hasPositionChange) return;
|
||||
|
||||
const isDragEnd = changes.some((c) => c.type === "position" && (c as any).dragging === false);
|
||||
|
||||
setNodes((prev) => {
|
||||
let changed = false;
|
||||
let nodes = [...prev];
|
||||
|
||||
// 1. Clamp Y only (prevent going above header), allow X freely
|
||||
nodes = nodes.map((n) => {
|
||||
if (!n.parentId) return n;
|
||||
const clampedY = Math.max(MIN_Y, n.position.y);
|
||||
if (clampedY !== n.position.y) {
|
||||
changed = true;
|
||||
return { ...n, position: { x: n.position.x, y: clampedY } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
// 2. For each group, keep leftmost child at MIN_X — shift group + children to match
|
||||
const groupIds = [...new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!))];
|
||||
for (const gid of groupIds) {
|
||||
const kids = nodes.filter((n) => n.parentId === gid);
|
||||
const minChildX = Math.min(...kids.map((k) => k.position.x));
|
||||
if (minChildX !== MIN_X) {
|
||||
const shift = minChildX - MIN_X; // positive = children too far right, negative = too far left
|
||||
changed = true;
|
||||
nodes = nodes.map((n) => {
|
||||
if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } };
|
||||
if (n.parentId === gid) return { ...n, position: { x: n.position.x - shift, y: n.position.y } };
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resize groups to fit children (grows AND shrinks)
|
||||
nodes = nodes.map((n) => {
|
||||
if (!n.id.startsWith("group-")) return n;
|
||||
const kids = nodes.filter((c) => c.parentId === n.id);
|
||||
if (kids.length === 0) return n;
|
||||
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
for (const k of kids) {
|
||||
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
|
||||
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
|
||||
}
|
||||
|
||||
const minW = NODE_W + G_PAD * 3;
|
||||
const newW = Math.max(maxRight, minW);
|
||||
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
|
||||
|
||||
const curW = (n.style?.width as number) || 0;
|
||||
const curH = (n.style?.height as number) || 0;
|
||||
|
||||
if (newW !== curW || newH !== curH) {
|
||||
changed = true;
|
||||
return { ...n, style: { ...n.style, width: newW, height: newH } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
if (isDragEnd) savePositions(nodes);
|
||||
return changed ? nodes : prev;
|
||||
});
|
||||
}, [onNodesChange, setNodes, savePositions]);
|
||||
|
||||
const projects = useMemo(() => [...new Set(services.map((s) => s.project))].sort(), [services]);
|
||||
|
||||
const toggleProject = (p: string) => {
|
||||
setHiddenProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
localStorage.setItem("df:filter", JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (filterRef.current && !filterRef.current.contains(e.target as HTMLElement)) {
|
||||
setFilterOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const filteredServices = useMemo(
|
||||
() => services.filter((s) => !hiddenProjects.has(s.project)),
|
||||
[services, hiddenProjects]
|
||||
);
|
||||
|
||||
const filteredConnections = useMemo(
|
||||
() => {
|
||||
const uids = new Set(filteredServices.map((s) => s.uid));
|
||||
return connections.filter((c) => uids.has(c.from) && uids.has(c.to));
|
||||
},
|
||||
[connections, filteredServices]
|
||||
);
|
||||
|
||||
// Build layout when data changes
|
||||
useEffect(() => {
|
||||
if (filteredServices.length === 0) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats);
|
||||
|
||||
if (!initialLayoutDone.current) {
|
||||
// Apply saved positions to all nodes (groups + services)
|
||||
let positioned = newNodes.map((n) => {
|
||||
const saved = savedPositions.current[n.id];
|
||||
if (saved) return { ...n, position: saved };
|
||||
return n;
|
||||
});
|
||||
// Recalculate group sizes based on actual child positions
|
||||
positioned = positioned.map((n) => {
|
||||
if (n.type !== "group") return n;
|
||||
const kids = positioned.filter((c) => c.parentId === n.id);
|
||||
if (kids.length === 0) return n;
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
for (const k of kids) {
|
||||
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
|
||||
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
|
||||
}
|
||||
const minW = NODE_W + G_PAD * 3;
|
||||
const newW = Math.max(maxRight, minW);
|
||||
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
|
||||
return { ...n, style: { ...n.style, width: newW, height: newH } };
|
||||
});
|
||||
// Compute edges + activeHandles based on positioned nodes
|
||||
const { edges, activeHandles } = computeEdges(positioned, filteredConnections);
|
||||
for (const n of positioned) {
|
||||
if (n.type === "service") {
|
||||
(n.data as any).activeHandles = activeHandles.get(n.id) || [];
|
||||
}
|
||||
}
|
||||
setNodes(positioned);
|
||||
setEdges(edges);
|
||||
initialLayoutDone.current = true;
|
||||
} else {
|
||||
setNodes((prev) => {
|
||||
// Keep existing nodes, update data only
|
||||
const updated = prev.map((n) => {
|
||||
const u = newNodes.find((nn) => nn.id === n.id);
|
||||
if (!u) return null;
|
||||
return { ...n, data: u.data };
|
||||
}).filter(Boolean) as Node[];
|
||||
|
||||
const existingIds = new Set(updated.map((n) => n.id));
|
||||
const brand = newNodes.filter((n) => !existingIds.has(n.id));
|
||||
|
||||
if (brand.length === 0) return updated;
|
||||
|
||||
// Apply saved positions to brand-new nodes (e.g. re-enabled project filter)
|
||||
const hasSaved = brand.some((n) => savedPositions.current[n.id]);
|
||||
if (hasSaved) {
|
||||
let positioned = brand.map((n) => {
|
||||
const saved = savedPositions.current[n.id];
|
||||
if (saved) return { ...n, position: saved };
|
||||
return n;
|
||||
});
|
||||
// Recalculate group sizes for restored nodes
|
||||
positioned = positioned.map((n) => {
|
||||
if (n.type !== "group") return n;
|
||||
const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id);
|
||||
if (kids.length === 0) return n;
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
for (const k of kids) {
|
||||
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
|
||||
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
|
||||
}
|
||||
const minW = NODE_W + G_PAD * 3;
|
||||
const newW = Math.max(maxRight, minW);
|
||||
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
|
||||
return { ...n, style: { ...n.style, width: newW, height: newH } };
|
||||
});
|
||||
return [...updated, ...positioned];
|
||||
}
|
||||
|
||||
// Find rightmost edge of existing groups to place new ones after
|
||||
let maxRightX = 0;
|
||||
for (const n of updated) {
|
||||
if (n.type === "group") {
|
||||
const w = (n.style?.width as number) || NODE_W + G_PAD * 3;
|
||||
maxRightX = Math.max(maxRightX, n.position.x + w);
|
||||
}
|
||||
}
|
||||
|
||||
// Offset new groups so they appear to the right
|
||||
const newGroups = brand.filter((n) => n.type === "group");
|
||||
const offsetX = maxRightX > 0 ? maxRightX + 50 - (newGroups[0]?.position.x || 0) : 0;
|
||||
|
||||
const positioned = brand.map((n) => {
|
||||
if (n.type === "group" && offsetX > 0) {
|
||||
return { ...n, position: { x: n.position.x + offsetX, y: n.position.y } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
return [...updated, ...positioned];
|
||||
});
|
||||
}
|
||||
}, [filteredServices, filteredConnections, statsVersion]);
|
||||
|
||||
// Recompute edges + handles whenever nodes move
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0 || filteredConnections.length === 0) return;
|
||||
const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections);
|
||||
setEdges(newEdges);
|
||||
// Update activeHandles on nodes
|
||||
setNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.type !== "service") return n;
|
||||
const handles = activeHandles.get(n.id) || [];
|
||||
const current = (n.data as any).activeHandles || [];
|
||||
// Skip if unchanged
|
||||
if (handles.length === current.length && handles.every((h: string, i: number) => h === current[i])) return n;
|
||||
return { ...n, data: { ...n.data, activeHandles: handles } };
|
||||
})
|
||||
);
|
||||
}, [nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","), filteredConnections]);
|
||||
|
||||
// Flash nodes on Docker events
|
||||
useEffect(() => {
|
||||
if (events.length === 0) return;
|
||||
const latest = events[events.length - 1]!;
|
||||
const flashClass =
|
||||
latest.action === "start"
|
||||
? "flash-start"
|
||||
: latest.action === "die" || latest.action === "stop"
|
||||
? "flash-stop"
|
||||
: latest.action === "restart"
|
||||
? "flash-restart"
|
||||
: "";
|
||||
|
||||
if (!flashClass) return;
|
||||
|
||||
setNodes((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === latest.service ? { ...n, data: { ...n.data, flash: flashClass } } : n
|
||||
)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
setNodes((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === latest.service ? { ...n, data: { ...n.data, flash: "" } } : n
|
||||
)
|
||||
);
|
||||
}, 1200);
|
||||
}, [events]);
|
||||
|
||||
const runningCount = filteredServices.filter((s) => s.state === "running").length;
|
||||
|
||||
// Highlight edges connected to selected node, dim the rest
|
||||
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;
|
||||
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 },
|
||||
};
|
||||
});
|
||||
}, [nodes, connectedNodeIds, selectedNode]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-slate-950 flex flex-col">
|
||||
{/* Header */}
|
||||
<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 gap-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src="/alteonx-logo.png"
|
||||
alt="Alteonx"
|
||||
className="w-7 h-7"
|
||||
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
||||
/>
|
||||
<span className="text-base font-bold text-white tracking-wide">
|
||||
DockerFlow
|
||||
</span>
|
||||
<span className="text-xs text-cyan-400 tracking-widest uppercase font-semibold">
|
||||
AlteonX
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 font-mono bg-slate-800 px-2 py-0.5 rounded">
|
||||
v0.1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Project filter dropdown */}
|
||||
{projects.length > 1 && (
|
||||
<div className="relative" ref={filterRef}>
|
||||
<button
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 hover:bg-slate-700/80 px-3 py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
Projects
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{projects.length - hiddenProjects.size}/{projects.length}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-slate-500 transition-transform ${filterOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{filterOpen && (
|
||||
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[200px] z-[9999]">
|
||||
{projects.map((p) => {
|
||||
const active = !hiddenProjects.has(p);
|
||||
const count = services.filter((s) => s.project === p).length;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => toggleProject(p)}
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
active ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
|
||||
}`}>
|
||||
{active && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className={active ? "text-slate-200" : "text-slate-500"}>{p}</span>
|
||||
<span className="text-slate-500 ml-auto">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<span className="text-sm text-slate-500">
|
||||
<span className="text-emerald-400 font-medium">{runningCount}</span>
|
||||
<span className="text-slate-600">/{filteredServices.length}</span>
|
||||
<span className="text-slate-600 ml-1">containers</span>
|
||||
</span>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Wifi size={15} className="text-emerald-500" />
|
||||
) : (
|
||||
<WifiOff size={15} className="text-red-500" />
|
||||
)}
|
||||
<span className={`text-xs ${connected ? "text-emerald-500" : "text-red-500"}`}>
|
||||
{connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Logout (only if auth is active) */}
|
||||
{token && (
|
||||
<button
|
||||
onClick={() => { localStorage.removeItem("df:token"); window.location.reload(); }}
|
||||
className="text-slate-600 hover:text-slate-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ReactFlow
|
||||
onInit={(instance) => { reactFlowRef.current = instance; }}
|
||||
nodes={styledNodes}
|
||||
edges={styledEdges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
onNodeDragStop={() => setSelectedNode(null)}
|
||||
onPaneClick={() => { setSelectedNode(null); setLogPanelService(null); }}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.2}
|
||||
maxZoom={2.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="#1e293b" gap={24} size={1} />
|
||||
<Controls position="bottom-left" />
|
||||
|
||||
{/* Edge legend */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-slate-900/90 border border-slate-800 rounded-lg px-5 py-2.5 z-10">
|
||||
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Conexiones</span>
|
||||
{[
|
||||
{ icon: Database, color: "#336791", label: "Database" },
|
||||
{ icon: Zap, color: "#F59E0B", label: "Cache" },
|
||||
{ icon: Radio, color: "#A855F7", label: "Broker" },
|
||||
{ icon: Globe, color: "#22C55E", label: "Proxy" },
|
||||
].map(({ icon: Icon, color, label }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className="w-5 h-0.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<Icon size={13} style={{ color }} />
|
||||
<span className="text-xs" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
nodeColor={(n) => {
|
||||
const state = (n.data as any)?.state;
|
||||
if (state === "running") return "#22c55e";
|
||||
if (state === "exited" || state === "dead") return "#ef4444";
|
||||
return "#f59e0b";
|
||||
}}
|
||||
style={{ background: "#0f172a" }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{logPanelService && (
|
||||
<LogPanel
|
||||
service={logPanelService}
|
||||
logLines={logLines}
|
||||
token={token}
|
||||
onClose={() => { setLogPanelService(null); setSelectedNode(null); }}
|
||||
sendMessage={sendMessage}
|
||||
clearLogLines={clearLogLines}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { Service, Connection, Stats } from "../../shared/types";
|
||||
|
||||
const NODE_WIDTH = 240;
|
||||
const NODE_HEIGHT = 160;
|
||||
const NODE_GAP_X = 36;
|
||||
const NODE_GAP_Y = 36;
|
||||
const GROUP_PADDING = 28;
|
||||
const GROUP_HEADER = 44;
|
||||
const GROUP_GAP = 50;
|
||||
|
||||
function getComposeKey(file: string): string {
|
||||
if (!file) return "default";
|
||||
const match = file.match(/docker-compose\.?(.*)\.yml/);
|
||||
const key = match?.[1] || "";
|
||||
if (key === "") return "prod";
|
||||
return key.replace(/^\./, "");
|
||||
}
|
||||
|
||||
function getGroupKey(service: Service): string {
|
||||
const compose = getComposeKey(service.compose_file);
|
||||
return `${service.project}/${compose}`;
|
||||
}
|
||||
|
||||
function getGroupLabel(key: string): string {
|
||||
// "ninjasagacw/infra" → "NINJASAGACW / INFRA"
|
||||
const parts = key.split("/");
|
||||
return parts.map((p) => p.toUpperCase()).join(" / ");
|
||||
}
|
||||
|
||||
// Color per group for visual distinction
|
||||
const GROUP_COLORS: Record<string, string> = {
|
||||
infra: "rgba(239, 68, 68, 0.08)",
|
||||
dev: "rgba(59, 130, 246, 0.08)",
|
||||
prod: "rgba(34, 197, 94, 0.08)",
|
||||
};
|
||||
|
||||
const GROUP_BORDER_COLORS: Record<string, string> = {
|
||||
infra: "rgba(239, 68, 68, 0.3)",
|
||||
dev: "rgba(59, 130, 246, 0.3)",
|
||||
prod: "rgba(34, 197, 94, 0.3)",
|
||||
};
|
||||
|
||||
// Dynamic colors for project groups not matching known names
|
||||
const DYNAMIC_COLORS = [
|
||||
{ bg: "rgba(139, 92, 246, 0.08)", border: "rgba(139, 92, 246, 0.3)" },
|
||||
{ bg: "rgba(6, 182, 212, 0.08)", border: "rgba(6, 182, 212, 0.3)" },
|
||||
{ bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.3)" },
|
||||
{ bg: "rgba(236, 72, 153, 0.08)", border: "rgba(236, 72, 153, 0.3)" },
|
||||
{ bg: "rgba(16, 185, 129, 0.08)", border: "rgba(16, 185, 129, 0.3)" },
|
||||
];
|
||||
let dynamicIdx = 0;
|
||||
const dynamicAssigned = new Map<string, (typeof DYNAMIC_COLORS)[0]>();
|
||||
|
||||
function getDynamicColor(key: string) {
|
||||
if (!dynamicAssigned.has(key)) {
|
||||
dynamicAssigned.set(key, DYNAMIC_COLORS[dynamicIdx % DYNAMIC_COLORS.length]!);
|
||||
dynamicIdx++;
|
||||
}
|
||||
return dynamicAssigned.get(key)!;
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export function buildLayout(
|
||||
services: Service[],
|
||||
connections: Connection[],
|
||||
statsMap: Map<string, Stats>
|
||||
): LayoutResult {
|
||||
if (services.length === 0) return { nodes: [], edges: [] };
|
||||
|
||||
// Group services by project/compose_file (always consistent)
|
||||
const groups = new Map<string, Service[]>();
|
||||
for (const svc of services) {
|
||||
const key = getGroupKey(svc);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(svc);
|
||||
}
|
||||
|
||||
const nodes: Node[] = [];
|
||||
|
||||
// Layout groups side by side
|
||||
const MAX_COLS_PER_GROUP = 3;
|
||||
let groupX = 0;
|
||||
let maxGroupHeight = 0;
|
||||
let groupRow = 0;
|
||||
const groupPositions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
const groupEntries = Array.from(groups.entries());
|
||||
|
||||
// Sort: infra groups first, then dev, then prod, then others
|
||||
const COMPOSE_ORDER = ["infra", "dev", "prod"];
|
||||
groupEntries.sort((a, b) => {
|
||||
// Sort by project first, then by compose key order
|
||||
const [projA, compA] = a[0].split("/");
|
||||
const [projB, compB] = b[0].split("/");
|
||||
if (projA !== projB) return projA.localeCompare(projB);
|
||||
const ai = COMPOSE_ORDER.indexOf(compA);
|
||||
const bi = COMPOSE_ORDER.indexOf(compB);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
|
||||
for (const [groupKey, svcs] of groupEntries) {
|
||||
const cols = Math.min(svcs.length, MAX_COLS_PER_GROUP);
|
||||
const rows = Math.ceil(svcs.length / cols);
|
||||
const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X;
|
||||
const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y;
|
||||
const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3);
|
||||
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_HEADER + GROUP_PADDING;
|
||||
|
||||
groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight });
|
||||
|
||||
if (groupHeight > maxGroupHeight) maxGroupHeight = groupHeight;
|
||||
|
||||
// Color based on compose part (infra/dev/prod), not full key
|
||||
const composePart = groupKey.split("/")[1] || groupKey;
|
||||
const knownBg = GROUP_COLORS[composePart];
|
||||
const knownBorder = GROUP_BORDER_COLORS[composePart];
|
||||
const dynamic = !knownBg ? getDynamicColor(groupKey) : null;
|
||||
const bgColor = knownBg || dynamic!.bg;
|
||||
const borderColor = knownBorder || dynamic!.border;
|
||||
|
||||
// Compose file subtitle — show unique compose files in this group
|
||||
const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))]
|
||||
.map((f) => f.split("/").pop() || "")
|
||||
.filter(Boolean);
|
||||
const subtitle = composeFiles.join(", ");
|
||||
|
||||
// Group node
|
||||
nodes.push({
|
||||
id: `group-${groupKey}`,
|
||||
type: "group",
|
||||
position: { x: groupX, y: 0 },
|
||||
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length },
|
||||
style: {
|
||||
width: groupWidth,
|
||||
height: groupHeight,
|
||||
border: `1px dashed ${borderColor}`,
|
||||
borderRadius: 16,
|
||||
background: bgColor,
|
||||
padding: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: borderColor.replace("0.3", "0.8"),
|
||||
},
|
||||
});
|
||||
|
||||
// Service nodes inside group (grid layout)
|
||||
svcs.forEach((svc, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = GROUP_PADDING + col * (NODE_WIDTH + NODE_GAP_X);
|
||||
const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y);
|
||||
|
||||
nodes.push({
|
||||
id: svc.uid,
|
||||
type: "service",
|
||||
parentId: `group-${groupKey}`,
|
||||
position: { x, y },
|
||||
data: {
|
||||
...svc,
|
||||
label: svc.name,
|
||||
stats: statsMap.get(svc.uid) || null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
groupX += groupWidth + GROUP_GAP;
|
||||
}
|
||||
|
||||
return { nodes, edges: [] };
|
||||
}
|
||||
|
||||
// ── Recompute edges + activeHandles based on current node positions ──
|
||||
|
||||
const EDGE_COLORS: Record<string, string> = {
|
||||
database: "#336791",
|
||||
cache: "#F59E0B",
|
||||
broker: "#A855F7",
|
||||
proxy: "#22C55E",
|
||||
};
|
||||
|
||||
export function computeEdges(
|
||||
currentNodes: Node[],
|
||||
connections: Connection[]
|
||||
): { edges: Edge[]; activeHandles: Map<string, string[]> } {
|
||||
// Build absolute positions from current nodes
|
||||
const absPositions = new Map<string, { x: number; y: number }>();
|
||||
for (const n of currentNodes) {
|
||||
if (n.parentId) {
|
||||
const parent = currentNodes.find((p) => p.id === n.parentId);
|
||||
if (parent) {
|
||||
absPositions.set(n.id, {
|
||||
x: parent.position.x + n.position.x + NODE_WIDTH / 2,
|
||||
y: parent.position.y + n.position.y + NODE_HEIGHT / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bestSide(fromId: string, toId: string): { sourceSide: string; targetSide: string } {
|
||||
const from = absPositions.get(fromId);
|
||||
const to = absPositions.get(toId);
|
||||
if (!from || !to) return { sourceSide: "bottom", targetSide: "top" };
|
||||
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
return dx > 0
|
||||
? { sourceSide: "right", targetSide: "left" }
|
||||
: { sourceSide: "left", targetSide: "right" };
|
||||
} else {
|
||||
return dy > 0
|
||||
? { sourceSide: "bottom", targetSide: "top" }
|
||||
: { sourceSide: "top", targetSide: "bottom" };
|
||||
}
|
||||
}
|
||||
|
||||
const nodeIds = new Set(currentNodes.map((n) => n.id));
|
||||
const validConnections = connections.filter((c) => nodeIds.has(c.from) && nodeIds.has(c.to));
|
||||
|
||||
// Group ALL connections (source + target) by node+side so edges on the
|
||||
// same side never share the same handle slot, regardless of direction.
|
||||
interface SlotEntry { conn: Connection; nodeId: string; side: string; role: "source" | "target" }
|
||||
const nodeSlotGroups = new Map<string, SlotEntry[]>();
|
||||
|
||||
for (const c of validConnections) {
|
||||
const { sourceSide, targetSide } = bestSide(c.from, c.to);
|
||||
|
||||
// Both source and target go into the SAME group per node+side
|
||||
const srcKey = `${c.from}:${sourceSide}`;
|
||||
if (!nodeSlotGroups.has(srcKey)) nodeSlotGroups.set(srcKey, []);
|
||||
nodeSlotGroups.get(srcKey)!.push({ conn: c, nodeId: c.from, side: sourceSide, role: "source" });
|
||||
|
||||
const tgtKey = `${c.to}:${targetSide}`;
|
||||
if (!nodeSlotGroups.has(tgtKey)) nodeSlotGroups.set(tgtKey, []);
|
||||
nodeSlotGroups.get(tgtKey)!.push({ conn: c, nodeId: c.to, side: targetSide, role: "target" });
|
||||
}
|
||||
|
||||
// Sort each group by position of the other node so slots align spatially
|
||||
const assignedSlots = new Map<string, number>();
|
||||
|
||||
for (const [groupKey, entries] of nodeSlotGroups) {
|
||||
const side = groupKey.split(":")[1]; // "left", "right", "top", "bottom"
|
||||
|
||||
// For left/right sides, sort by Y of the other node (top→bottom = slot 0→2)
|
||||
// For top/bottom sides, sort by X of the other node (left→right = slot 0→2)
|
||||
entries.sort((a, b) => {
|
||||
const otherA = absPositions.get(a.role === "source" ? a.conn.to : a.conn.from);
|
||||
const otherB = absPositions.get(b.role === "source" ? b.conn.to : b.conn.from);
|
||||
if (!otherA || !otherB) return 0;
|
||||
if (side === "left" || side === "right") return otherA.y - otherB.y;
|
||||
return otherA.x - otherB.x;
|
||||
});
|
||||
|
||||
// If only 1 edge on this side, use middle slot (1)
|
||||
// If 2, use slots 0 and 2. If 3, use 0, 1, 2
|
||||
const slotMap: number[][] = [
|
||||
[1], // 1 edge → middle
|
||||
[0, 2], // 2 edges → top/left and bottom/right
|
||||
[0, 1, 2], // 3 edges → all
|
||||
];
|
||||
const slots = slotMap[Math.min(entries.length, 3) - 1];
|
||||
|
||||
entries.forEach((entry, i) => {
|
||||
const slot = slots[Math.min(i, slots.length - 1)];
|
||||
const key = `${entry.conn.from}-${entry.conn.to}:${entry.nodeId}:${entry.role}`;
|
||||
assignedSlots.set(key, slot);
|
||||
});
|
||||
}
|
||||
|
||||
// Build edges first, then compute corridor offsets
|
||||
interface EdgeInfo {
|
||||
conn: Connection;
|
||||
sourceSide: string;
|
||||
targetSide: string;
|
||||
srcSlot: number;
|
||||
tgtSlot: number;
|
||||
}
|
||||
|
||||
const edgeInfos: EdgeInfo[] = validConnections.map((c) => {
|
||||
const { sourceSide, targetSide } = bestSide(c.from, c.to);
|
||||
const srcSlot = assignedSlots.get(`${c.from}-${c.to}:${c.from}:source`) ?? 1;
|
||||
const tgtSlot = assignedSlots.get(`${c.from}-${c.to}:${c.to}:target`) ?? 1;
|
||||
return { conn: c, sourceSide, targetSide, srcSlot, tgtSlot };
|
||||
});
|
||||
|
||||
// Assign unique offsets per edge so smoothstep turns don't overlap
|
||||
// Each edge from same node+side gets a different turn distance
|
||||
const sideGroups = new Map<string, EdgeInfo[]>();
|
||||
for (const ei of edgeInfos) {
|
||||
const srcKey = `${ei.conn.from}:${ei.sourceSide}`;
|
||||
if (!sideGroups.has(srcKey)) sideGroups.set(srcKey, []);
|
||||
sideGroups.get(srcKey)!.push(ei);
|
||||
|
||||
const tgtKey = `${ei.conn.to}:${ei.targetSide}`;
|
||||
if (!sideGroups.has(tgtKey)) sideGroups.set(tgtKey, []);
|
||||
sideGroups.get(tgtKey)!.push(ei);
|
||||
}
|
||||
|
||||
const edgeOffsets = new Map<string, number>();
|
||||
const BASE_OFFSET = 10;
|
||||
const OFFSET_STEP = 15;
|
||||
|
||||
for (const [, group] of sideGroups) {
|
||||
if (group.length <= 1) continue;
|
||||
group.forEach((ei, i) => {
|
||||
const eid = `${ei.conn.from}-${ei.conn.to}`;
|
||||
// Always positive: BASE + incremental step (never goes into node)
|
||||
const newOffset = BASE_OFFSET + i * OFFSET_STEP;
|
||||
const existing = edgeOffsets.get(eid);
|
||||
if (existing === undefined || newOffset > existing) {
|
||||
edgeOffsets.set(eid, newOffset);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const active = new Map<string, Set<string>>();
|
||||
const edges: Edge[] = edgeInfos.map((ei) => {
|
||||
const c = ei.conn;
|
||||
const color = EDGE_COLORS[c.type || ""] || "#475569";
|
||||
|
||||
const sourceHandle = `${ei.sourceSide}-${ei.srcSlot}`;
|
||||
const targetHandle = `${ei.targetSide}-${ei.tgtSlot}-target`;
|
||||
|
||||
if (!active.has(c.from)) active.set(c.from, new Set());
|
||||
if (!active.has(c.to)) active.set(c.to, new Set());
|
||||
active.get(c.from)!.add(sourceHandle);
|
||||
active.get(c.to)!.add(`${ei.targetSide}-${ei.tgtSlot}`);
|
||||
|
||||
const offset = edgeOffsets.get(`${c.from}-${c.to}`) ?? 0;
|
||||
|
||||
return {
|
||||
id: `${c.from}-${c.to}`,
|
||||
source: c.from,
|
||||
target: c.to,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
label: c.label || "",
|
||||
type: "offsetSmooth",
|
||||
animated: false,
|
||||
data: { offset },
|
||||
style: { stroke: color, strokeWidth: 2, opacity: 0.7 },
|
||||
labelStyle: { fill: color, fontSize: 11, fontWeight: 500 },
|
||||
labelBgStyle: { fill: "#0f172a", fillOpacity: 1 },
|
||||
labelBgPadding: [6, 3] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
};
|
||||
});
|
||||
|
||||
const activeHandles = new Map<string, string[]>();
|
||||
for (const [id, set] of active) activeHandles.set(id, [...set]);
|
||||
|
||||
return { edges, activeHandles };
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
||||
|
||||
function arraysEqual<T extends { uid?: string; name?: string }>(a: T[], b: T[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if ((a[i] as any).uid !== (b[i] as any).uid) return false;
|
||||
if ((a[i] as any).state !== (b[i] as any).state) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useDocker(token = "") {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const statsRef = useRef<Map<string, Stats>>(new Map());
|
||||
const [statsVersion, setStatsVersion] = useState(0);
|
||||
const [events, setEvents] = useState<DockerEvent[]>([]);
|
||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Initial HTTP fetch so data loads even if WS is slow
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []),
|
||||
fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []),
|
||||
]).then(([svcs, conns]) => {
|
||||
setServices((prev) => prev.length === 0 ? svcs : prev);
|
||||
setConnections((prev) => prev.length === 0 ? conns : prev);
|
||||
}).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Clean up any existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const params = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws${params}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(e.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case "services":
|
||||
setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data);
|
||||
break;
|
||||
case "connections":
|
||||
setConnections((prev) => {
|
||||
if (prev.length === msg.data.length) return prev;
|
||||
return msg.data;
|
||||
});
|
||||
break;
|
||||
case "stats": {
|
||||
let changed = false;
|
||||
for (const s of msg.data) {
|
||||
const existing = statsRef.current.get(s.service);
|
||||
if (!existing || existing.cpu !== s.cpu || existing.mem_mb !== s.mem_mb) {
|
||||
statsRef.current.set(s.service, s);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) setStatsVersion((v) => v + 1);
|
||||
break;
|
||||
}
|
||||
case "docker_event":
|
||||
setEvents((prev) => {
|
||||
if (prev.length >= 10) return [...prev.slice(-9), msg.data];
|
||||
return [...prev, msg.data];
|
||||
});
|
||||
break;
|
||||
case "log_line":
|
||||
setLogLines((prev) => {
|
||||
const next = [...prev, msg.data];
|
||||
return next.length > 2000 ? next.slice(-1500) : next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
setConnected(false);
|
||||
} else if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const sendMessage = useCallback((msg: WSMessage) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearLogLines = useCallback(() => setLogLines([]), []);
|
||||
|
||||
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-node-running: #22c55e;
|
||||
--color-node-stopped: #ef4444;
|
||||
--color-node-paused: #f59e0b;
|
||||
}
|
||||
|
||||
/* React Flow overrides */
|
||||
.react-flow__background {
|
||||
background-color: #020617 !important;
|
||||
}
|
||||
|
||||
.react-flow__minimap {
|
||||
background-color: #0f172a !important;
|
||||
border: 1px solid #1e293b !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
border: 1px solid #1e293b !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background-color: #1e293b !important;
|
||||
color: #94a3b8 !important;
|
||||
border-bottom: 1px solid #334155 !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background-color: #334155 !important;
|
||||
}
|
||||
|
||||
/* Pulse animation for running nodes */
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
|
||||
.node-pulse-running {
|
||||
animation: pulse-ring 2s infinite;
|
||||
}
|
||||
|
||||
/* Flash animations for Docker events */
|
||||
@keyframes flash-green {
|
||||
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
||||
50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes flash-red {
|
||||
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
||||
50% { box-shadow: 0 0 20px 4px rgba(239, 68, 68, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes flash-yellow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
||||
50% { box-shadow: 0 0 20px 4px rgba(245, 158, 11, 0.6); }
|
||||
}
|
||||
|
||||
.flash-start { animation: flash-green 0.6s ease-out; }
|
||||
.flash-stop { animation: flash-red 0.6s ease-out; }
|
||||
.flash-restart { animation: flash-yellow 0.6s ease-out 2; }
|
||||
|
||||
/* Log panel slide-up */
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
animation: slideUp 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Login animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DockerFlow AlteonX</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
</head>
|
||||
<body class="bg-slate-950 text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { memo } from "react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import { Server, Wrench, Rocket, Box, Folder } from "lucide-react";
|
||||
|
||||
interface GroupNodeData {
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const groupConfig: Record<string, { icon: typeof Server; color: string; borderColor: string }> = {
|
||||
INFRA: { icon: Server, color: "#ef4444", borderColor: "rgba(239, 68, 68, 0.3)" },
|
||||
DEV: { icon: Wrench, color: "#3b82f6", borderColor: "rgba(59, 130, 246, 0.3)" },
|
||||
PROD: { icon: Rocket, color: "#22c55e", borderColor: "rgba(34, 197, 94, 0.3)" },
|
||||
};
|
||||
|
||||
// Rotating colors for project-based groups that don't match known names
|
||||
const projectColors = [
|
||||
{ color: "#8b5cf6", borderColor: "rgba(139, 92, 246, 0.3)" },
|
||||
{ color: "#06b6d4", borderColor: "rgba(6, 182, 212, 0.3)" },
|
||||
{ color: "#f59e0b", borderColor: "rgba(245, 158, 11, 0.3)" },
|
||||
{ color: "#ec4899", borderColor: "rgba(236, 72, 153, 0.3)" },
|
||||
{ color: "#10b981", borderColor: "rgba(16, 185, 129, 0.3)" },
|
||||
];
|
||||
|
||||
let colorIndex = 0;
|
||||
const assignedColors = new Map<string, (typeof projectColors)[0]>();
|
||||
|
||||
function getProjectColor(label: string) {
|
||||
if (!assignedColors.has(label)) {
|
||||
assignedColors.set(label, projectColors[colorIndex % projectColors.length]!);
|
||||
colorIndex++;
|
||||
}
|
||||
return assignedColors.get(label)!;
|
||||
}
|
||||
|
||||
export const GroupNode = memo(function GroupNode({ data }: NodeProps) {
|
||||
const d = data as unknown as GroupNodeData;
|
||||
// Label is "PROJECT / COMPOSE" — match compose part for known colors
|
||||
const parts = d.label.split(" / ");
|
||||
const composePart = parts.length > 1 ? parts[parts.length - 1] : d.label;
|
||||
const known = groupConfig[composePart];
|
||||
const proj = known ? null : getProjectColor(d.label);
|
||||
const config = known || { icon: Folder, color: proj!.color, borderColor: proj!.borderColor };
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 right-0 px-5 py-2.5 flex items-center gap-2.5">
|
||||
<Icon size={16} style={{ color: config.color }} />
|
||||
<span
|
||||
className="text-sm font-semibold tracking-wider uppercase"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{d.label}
|
||||
</span>
|
||||
{d.subtitle && (
|
||||
<span className="text-xs text-slate-600 font-mono truncate max-w-[220px]">
|
||||
{d.subtitle}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: config.borderColor }} />
|
||||
{d.count != null && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Box size={12} style={{ color: config.borderColor }} />
|
||||
<span className="text-xs font-mono" style={{ color: config.borderColor }}>
|
||||
{d.count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Database,
|
||||
Zap,
|
||||
Globe,
|
||||
Server,
|
||||
Container,
|
||||
Shield,
|
||||
Cog,
|
||||
Timer,
|
||||
Radar,
|
||||
MonitorDot,
|
||||
KeyRound,
|
||||
FileCode,
|
||||
Boxes,
|
||||
Gem,
|
||||
Coffee,
|
||||
Bug,
|
||||
Rabbit,
|
||||
Mail,
|
||||
BarChart3,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Stats } from "../../shared/types";
|
||||
|
||||
interface ServiceNodeData {
|
||||
label: string;
|
||||
image: string;
|
||||
state: string;
|
||||
ports: { host: number; container: number }[];
|
||||
project: string;
|
||||
stats: Stats | null;
|
||||
flash?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const stateStyles: Record<string, { ring: string; dot: string; bg: string }> = {
|
||||
running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10" },
|
||||
exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" },
|
||||
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" },
|
||||
restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" },
|
||||
dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" },
|
||||
};
|
||||
|
||||
// Map image/name patterns to Lucide icons and colors
|
||||
const iconMap: { pattern: string; icon: LucideIcon; color: string }[] = [
|
||||
{ pattern: "postgres", icon: Database, color: "#336791" },
|
||||
{ pattern: "mysql", icon: Database, color: "#4479A1" },
|
||||
{ pattern: "mariadb", icon: Database, color: "#003545" },
|
||||
{ pattern: "mongo", icon: Database, color: "#47A248" },
|
||||
{ pattern: "redis", icon: Zap, color: "#DC382D" },
|
||||
{ pattern: "memcached", icon: Zap, color: "#3B9C60" },
|
||||
{ pattern: "nginx", icon: Globe, color: "#009639" },
|
||||
{ pattern: "traefik", icon: Globe, color: "#24A1C1" },
|
||||
{ pattern: "haproxy", icon: Globe, color: "#2E86C1" },
|
||||
{ pattern: "caddy", icon: Globe, color: "#1F88E5" },
|
||||
{ pattern: "node", icon: MonitorDot, color: "#339933" },
|
||||
{ pattern: "python", icon: FileCode, color: "#3776AB" },
|
||||
{ pattern: "golang", icon: Boxes, color: "#00ADD8" },
|
||||
{ pattern: "ruby", icon: Gem, color: "#CC342D" },
|
||||
{ pattern: "java", icon: Coffee, color: "#ED8B00" },
|
||||
{ pattern: "rabbitmq", icon: Rabbit, color: "#FF6600" },
|
||||
{ pattern: "kafka", icon: Mail, color: "#231F20" },
|
||||
{ pattern: "grafana", icon: BarChart3, color: "#F46800" },
|
||||
{ pattern: "prometheus", icon: BarChart3, color: "#E6522C" },
|
||||
{ pattern: "certbot", icon: Shield, color: "#003A70" },
|
||||
];
|
||||
|
||||
// Name-based patterns (for custom-built images)
|
||||
const nameIconMap: { pattern: string; icon: LucideIcon; color: string }[] = [
|
||||
{ pattern: "collector", icon: Radar, color: "#f59e0b" },
|
||||
{ pattern: "celery", icon: Cog, color: "#97C95F" },
|
||||
{ pattern: "worker", icon: Cog, color: "#97C95F" },
|
||||
{ pattern: "beat", icon: Timer, color: "#97C95F" },
|
||||
{ pattern: "auth", icon: KeyRound, color: "#8b5cf6" },
|
||||
{ pattern: "backend", icon: Server, color: "#3b82f6" },
|
||||
{ pattern: "frontend", icon: MonitorDot, color: "#06b6d4" },
|
||||
{ pattern: "api", icon: Server, color: "#3b82f6" },
|
||||
];
|
||||
|
||||
function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } {
|
||||
const lowerImage = image.toLowerCase();
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
// Check image first
|
||||
for (const { pattern, icon, color } of iconMap) {
|
||||
if (lowerImage.includes(pattern)) return { Icon: icon, color };
|
||||
}
|
||||
|
||||
// Then check name
|
||||
for (const { pattern, icon, color } of nameIconMap) {
|
||||
if (lowerName.includes(pattern)) return { Icon: icon, color };
|
||||
}
|
||||
|
||||
return { Icon: Container, color: "#64748b" };
|
||||
}
|
||||
|
||||
export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
||||
const d = data as unknown as ServiceNodeData;
|
||||
const s = stateStyles[d.state] || stateStyles.exited;
|
||||
const { Icon, color: iconColor } = guessIcon(d.image, d.label);
|
||||
const flashClass = d.flash || "";
|
||||
const activeHandles = new Set<string>((d as any).activeHandles || []);
|
||||
const highlighted = (d as any).highlighted;
|
||||
const hdot = (id: string) => {
|
||||
if (activeHandles.has(id)) {
|
||||
return highlighted
|
||||
? "!bg-cyan-400 !w-2 !h-2 !border-0 !opacity-100"
|
||||
: "!bg-slate-500 !w-1.5 !h-1.5 !border-0 !opacity-80";
|
||||
}
|
||||
return "!bg-transparent !w-1.5 !h-1.5 !border-0 !opacity-0";
|
||||
};
|
||||
|
||||
// 3 slots per side at 25%, 50%, 75%
|
||||
const offsets = ["25%", "50%", "75%"];
|
||||
|
||||
return (
|
||||
<div
|
||||
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${(d as any).id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`}
|
||||
className={`relative rounded-xl border border-slate-700/80 ${s.bg} backdrop-blur-sm
|
||||
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring}
|
||||
transition-all duration-500 ${flashClass}
|
||||
${d.state === "running" ? "node-pulse-running" : ""}`}
|
||||
>
|
||||
{/* Top handles — left offset, transform centered horizontally */}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`t${i}`} type="source" position={Position.Top} id={`top-${i}`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
|
||||
))}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`tt${i}`} type="target" position={Position.Top} id={`top-${i}-target`} className={hdot(`top-${i}`)} style={{ left: o, transform: "translate(-50%, -50%)" }} />
|
||||
))}
|
||||
{/* Left handles — top offset, transform centered vertically */}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`l${i}`} type="source" position={Position.Left} id={`left-${i}`} className={hdot(`left-${i}`)} style={{ top: o, transform: "translate(-50%, -50%)" }} />
|
||||
))}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`lt${i}`} type="target" position={Position.Left} id={`left-${i}-target`} className={hdot(`left-${i}`)} style={{ top: o, transform: "translate(-50%, -50%)" }} />
|
||||
))}
|
||||
{/* Right handles — top offset, transform centered */}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`r${i}`} type="source" position={Position.Right} id={`right-${i}`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
|
||||
))}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`rt${i}`} type="target" position={Position.Right} id={`right-${i}-target`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
|
||||
))}
|
||||
|
||||
{/* Header: icon + name + status dot */}
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<div
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg"
|
||||
style={{ backgroundColor: `${iconColor}22` }}
|
||||
>
|
||||
<Icon size={18} style={{ color: iconColor }} />
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm truncate">{d.label}</span>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${s.dot}
|
||||
${d.state === "running" ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="text-xs text-slate-500 truncate mb-2 pl-10">{d.image}</div>
|
||||
|
||||
{/* Ports */}
|
||||
{d.ports?.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-2 pl-10">
|
||||
{d.ports.map((p) => (
|
||||
<span
|
||||
key={`${p.host}:${p.container}`}
|
||||
className="text-[11px] bg-slate-800/80 text-cyan-400 px-2 py-0.5 rounded font-mono"
|
||||
>
|
||||
:{p.host}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{d.stats && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="flex justify-between text-[11px] text-slate-400">
|
||||
<span>CPU {d.stats.cpu.toFixed(1)}%</span>
|
||||
<span>MEM {d.stats.mem_mb.toFixed(0)}MB</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700"
|
||||
style={{ width: `${Math.min(d.stats.cpu, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-violet-500/60 rounded-full transition-all duration-700"
|
||||
style={{ width: `${Math.min(d.stats.mem_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom handles — left offset, transform centered */}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`b${i}`} type="source" position={Position.Bottom} id={`bottom-${i}`} className={hdot(`bottom-${i}`)} style={{ left: o, transform: "translate(-50%, 50%)" }} />
|
||||
))}
|
||||
{offsets.map((o, i) => (
|
||||
<Handle key={`bt${i}`} type="target" position={Position.Bottom} id={`bottom-${i}-target`} className={hdot(`bottom-${i}`)} style={{ left: o, transform: "translate(-50%, 50%)" }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Pause, Play, Terminal } from "lucide-react";
|
||||
import type { Service, LogLine, WSMessage } from "../../shared/types";
|
||||
|
||||
interface LogPanelProps {
|
||||
service: Service;
|
||||
logLines: LogLine[];
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
sendMessage: (msg: WSMessage) => void;
|
||||
clearLogLines: () => void;
|
||||
}
|
||||
|
||||
export function LogPanel({ service, logLines, token, onClose, sendMessage, clearLogLines }: LogPanelProps) {
|
||||
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);
|
||||
|
||||
// Fetch initial logs + subscribe to streaming
|
||||
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));
|
||||
|
||||
// Subscribe to live logs
|
||||
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]);
|
||||
|
||||
// Detect manual scroll
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="log-panel shrink-0 flex flex-col bg-slate-900/95 backdrop-blur-sm border-t border-slate-700/80" style={{ height: "320px" }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-800 bg-slate-900 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal size={14} className="text-cyan-400" />
|
||||
<span className="text-sm font-medium text-white">{service.name}</span>
|
||||
<span className={`text-xs font-mono ${stateColor}`}>{service.state}</span>
|
||||
{service.state === "running" && subscribedRef.current && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
streaming
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAutoScroll((v) => !v)}
|
||||
className="p-1.5 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={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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>
|
||||
|
||||
{/* Log body */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs leading-5 px-4 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-3 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>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,245 @@
|
||||
import Docker from "dockerode";
|
||||
import type { Service, Connection, LogLine } from "../shared/types";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
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) => {
|
||||
const name = c.Labels["com.docker.compose.service"] || c.Names[0]?.replace("/", "") || "unknown";
|
||||
const project = c.Labels["com.docker.compose.project"] || "standalone";
|
||||
return {
|
||||
id: c.Id.slice(0, 12),
|
||||
uid: `${project}/${name}`,
|
||||
name,
|
||||
image: c.Image,
|
||||
state: c.State as Service["state"],
|
||||
status: c.Status,
|
||||
ports: [...new Map(
|
||||
c.Ports.filter((p) => p.PublicPort).map((p) => [
|
||||
`${p.PublicPort}:${p.PrivatePort}`,
|
||||
{ host: p.PublicPort!, container: p.PrivatePort },
|
||||
])
|
||||
).values()],
|
||||
networks: Object.keys(c.NetworkSettings?.Networks || {}),
|
||||
project,
|
||||
compose_file: c.Labels["com.docker.compose.project.config_files"] || "",
|
||||
};
|
||||
});
|
||||
|
||||
if (!all && projects.length > 0) {
|
||||
services = services.filter((s) => projects.includes(s.project));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// ── Infrastructure service detection ──
|
||||
// These are services that OTHER services connect TO (databases, caches, brokers, proxies)
|
||||
|
||||
const INFRA_PATTERNS: { pattern: string; type: string; label: string; role: "target" }[] = [
|
||||
{ pattern: "postgres", type: "database", label: "postgres", role: "target" },
|
||||
{ pattern: "mysql", type: "database", label: "mysql", role: "target" },
|
||||
{ pattern: "mariadb", type: "database", label: "mariadb", role: "target" },
|
||||
{ pattern: "mongo", type: "database", label: "mongo", role: "target" },
|
||||
{ pattern: "redis", type: "cache", label: "redis", role: "target" },
|
||||
{ pattern: "memcached", type: "cache", label: "memcached", role: "target" },
|
||||
{ pattern: "rabbitmq", type: "broker", label: "rabbitmq", role: "target" },
|
||||
{ pattern: "kafka", type: "broker", label: "kafka", role: "target" },
|
||||
{ pattern: "nats", type: "broker", label: "nats", role: "target" },
|
||||
];
|
||||
|
||||
const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"];
|
||||
|
||||
function isInfraService(svc: Service): { type: string; label: string } | null {
|
||||
const img = svc.image.toLowerCase();
|
||||
const name = svc.name.toLowerCase();
|
||||
for (const p of INFRA_PATTERNS) {
|
||||
if (img.includes(p.pattern) || name.includes(p.pattern)) {
|
||||
return { type: p.type, label: p.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProxyService(svc: Service): boolean {
|
||||
const img = svc.image.toLowerCase();
|
||||
const name = svc.name.toLowerCase();
|
||||
return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p));
|
||||
}
|
||||
|
||||
function isWorkerService(svc: Service): boolean {
|
||||
const name = svc.name.toLowerCase();
|
||||
return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron");
|
||||
}
|
||||
|
||||
export async function discoverConnections(services: Service[]): Promise<Connection[]> {
|
||||
const connections: Connection[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Separate services by role
|
||||
const infraServices = services.filter((s) => isInfraService(s));
|
||||
const proxyServices = services.filter((s) => isProxyService(s));
|
||||
const workerServices = services.filter((s) => isWorkerService(s));
|
||||
const appServices = services.filter(
|
||||
(s) => !isInfraService(s) && !isProxyService(s) && !isWorkerService(s)
|
||||
);
|
||||
|
||||
function addConnection(from: string, to: string, type: string, label: string) {
|
||||
const key = `${from}:${to}`;
|
||||
if (seen.has(key) || from === to) return;
|
||||
seen.add(key);
|
||||
connections.push({ from, to, network: "", type, label });
|
||||
}
|
||||
|
||||
// 1. App services → infra services (backend→db, backend→redis, etc.)
|
||||
for (const app of appServices) {
|
||||
// Each app connects to DB and cache in the same network
|
||||
for (const infra of infraServices) {
|
||||
const hasSharedNetwork = app.networks.some((n) => infra.networks.includes(n));
|
||||
if (!hasSharedNetwork) continue;
|
||||
const edge = isInfraService(infra)!;
|
||||
addConnection(app.uid, infra.uid, edge.type, edge.label);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Workers → infra (celery→redis as broker, celery→db)
|
||||
for (const worker of workerServices) {
|
||||
for (const infra of infraServices) {
|
||||
const hasSharedNetwork = worker.networks.some((n) => infra.networks.includes(n));
|
||||
if (!hasSharedNetwork) continue;
|
||||
const edge = isInfraService(infra)!;
|
||||
const label = edge.type === "cache" ? "broker" : edge.label;
|
||||
addConnection(worker.uid, infra.uid, edge.type, label);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Proxy → app services (nginx→backend, nginx→frontend, nginx→auth)
|
||||
for (const proxy of proxyServices) {
|
||||
for (const app of appServices) {
|
||||
const hasSharedNetwork = proxy.networks.some((n) => app.networks.includes(n));
|
||||
if (!hasSharedNetwork) continue;
|
||||
addConnection(proxy.uid, app.uid, "proxy", "upstream");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Collector → infra (special: collector writes to db and redis)
|
||||
for (const svc of services) {
|
||||
if (svc.name.toLowerCase().includes("collector")) {
|
||||
for (const infra of infraServices) {
|
||||
const hasSharedNetwork = svc.networks.some((n) => infra.networks.includes(n));
|
||||
if (!hasSharedNetwork) continue;
|
||||
const edge = isInfraService(infra)!;
|
||||
addConnection(svc.uid, infra.uid, edge.type, edge.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
// ── Container logs ──
|
||||
|
||||
export async function getContainerLogs(id: string, tail = 200): Promise<LogLine[]> {
|
||||
const container = docker.getContainer(id);
|
||||
const logBuffer = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
const lines: LogLine[] = [];
|
||||
const raw = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer as any);
|
||||
|
||||
let offset = 0;
|
||||
while (offset < raw.length) {
|
||||
if (offset + 8 > raw.length) break;
|
||||
const streamType = raw[offset];
|
||||
const size = raw.readUInt32BE(offset + 4);
|
||||
if (offset + 8 + size > raw.length) break;
|
||||
|
||||
const payload = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd();
|
||||
offset += 8 + size;
|
||||
|
||||
if (!payload) continue;
|
||||
|
||||
// Timestamp is at the start: "2024-01-01T00:00:00.000000000Z rest of line"
|
||||
const spaceIdx = payload.indexOf(" ");
|
||||
const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : "";
|
||||
const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload;
|
||||
|
||||
lines.push({
|
||||
container: id,
|
||||
line,
|
||||
timestamp,
|
||||
stream: streamType === 2 ? "stderr" : "stdout",
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function streamContainerLogs(
|
||||
id: string,
|
||||
onLine: (line: LogLine) => void,
|
||||
): { destroy: () => void } {
|
||||
const container = docker.getContainer(id);
|
||||
let stream: NodeJS.ReadableStream | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: true,
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
timestamps: true,
|
||||
}).then((s) => {
|
||||
if (destroyed) {
|
||||
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
|
||||
return;
|
||||
}
|
||||
stream = s as unknown as NodeJS.ReadableStream;
|
||||
|
||||
// Docker multiplexed stream parsing for follow mode
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
while (buffer.length >= 8) {
|
||||
const streamType = buffer[0];
|
||||
const size = buffer.readUInt32BE(4);
|
||||
if (buffer.length < 8 + size) break;
|
||||
|
||||
const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd();
|
||||
buffer = buffer.slice(8 + size);
|
||||
|
||||
if (!payload) continue;
|
||||
|
||||
const spaceIdx = payload.indexOf(" ");
|
||||
const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : "";
|
||||
const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload;
|
||||
|
||||
onLine({
|
||||
container: id,
|
||||
line,
|
||||
timestamp,
|
||||
stream: streamType === 2 ? "stderr" : "stdout",
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
if (stream && typeof (stream as any).destroy === "function") {
|
||||
(stream as any).destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||
import { pollStats, watchDockerEvents } from "./watcher";
|
||||
import type { WSMessage } from "../shared/types";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ── CLI args ──
|
||||
const args = process.argv.slice(2);
|
||||
const ALL = args.includes("--all");
|
||||
const projectsFlag = args.find((a) => a.startsWith("--projects="));
|
||||
const PROJECTS = projectsFlag
|
||||
? projectsFlag.split("=")[1]!.split(",")
|
||||
: ALL
|
||||
? []
|
||||
: [path.basename(process.cwd())];
|
||||
|
||||
// ── Config ──
|
||||
const PORT = parseInt(process.env.PORT || "9470");
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || "";
|
||||
const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
|
||||
|
||||
// ── Auth middleware ──
|
||||
if (AUTH_TOKEN) {
|
||||
app.use("*", async (c, next) => {
|
||||
// Skip static assets and auth page
|
||||
if (c.req.path === "/" || c.req.path.startsWith("/assets") || c.req.path.endsWith(".png") || c.req.path.endsWith(".ico")) return next();
|
||||
if (c.req.path === "/api/auth") return next();
|
||||
|
||||
const token = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||
if (token !== AUTH_TOKEN) return c.json({ error: "Unauthorized" }, 401);
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
// ── API ──
|
||||
app.get("/api/services", async (c) => {
|
||||
const services = await discoverServices(ALL, PROJECTS);
|
||||
return c.json(services);
|
||||
});
|
||||
|
||||
app.get("/api/connections", async (c) => {
|
||||
const services = await discoverServices(ALL, PROJECTS);
|
||||
const connections = await discoverConnections(services);
|
||||
return c.json(connections);
|
||||
});
|
||||
|
||||
app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS }));
|
||||
|
||||
app.get("/api/logs/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const tail = parseInt(c.req.query("tail") || "200");
|
||||
try {
|
||||
const lines = await getContainerLogs(id, tail);
|
||||
return c.json(lines);
|
||||
} catch (err) {
|
||||
return c.json({ error: "Failed to fetch logs" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Node positions (persisted to file) ──
|
||||
const POSITIONS_FILE = path.join(process.cwd(), ".dockerflow-positions.json");
|
||||
|
||||
app.get("/api/positions", (c) => {
|
||||
try {
|
||||
if (fs.existsSync(POSITIONS_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
|
||||
return c.json(data);
|
||||
}
|
||||
} catch {}
|
||||
return c.json({});
|
||||
});
|
||||
|
||||
app.put("/api/positions", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
fs.writeFileSync(POSITIONS_FILE, JSON.stringify(body, null, 2));
|
||||
return c.json({ ok: true });
|
||||
} catch {
|
||||
return c.json({ error: "Failed to save" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Serve frontend build ──
|
||||
app.use("/*", serveStatic({ root: "./dist" }));
|
||||
app.get("/*", serveStatic({ root: "./dist", path: "index.html" }));
|
||||
|
||||
// ── WebSocket ──
|
||||
const clients = new Set<WebSocket>();
|
||||
const logStreams = new Map<WebSocket, { destroy: () => void }>();
|
||||
|
||||
function broadcast(msg: WSMessage) {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const ws of clients) {
|
||||
try {
|
||||
ws.send(data);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupLogStream(ws: WebSocket) {
|
||||
const stream = logStreams.get(ws);
|
||||
if (stream) {
|
||||
stream.destroy();
|
||||
logStreams.delete(ws);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Docker events ──
|
||||
watchDockerEvents((event) => {
|
||||
broadcast({ type: "docker_event", data: event });
|
||||
});
|
||||
|
||||
// ── Stats polling ──
|
||||
let lastServicesHash = "";
|
||||
let lastConnectionsHash = "";
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const services = await discoverServices(ALL, PROJECTS);
|
||||
const connections = await discoverConnections(services);
|
||||
const stats = await pollStats(services);
|
||||
|
||||
// Only send services/connections if changed
|
||||
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
|
||||
if (svcHash !== lastServicesHash) {
|
||||
lastServicesHash = svcHash;
|
||||
broadcast({ type: "services", data: services });
|
||||
}
|
||||
|
||||
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
|
||||
if (connHash !== lastConnectionsHash) {
|
||||
lastConnectionsHash = connHash;
|
||||
broadcast({ type: "connections", data: connections });
|
||||
}
|
||||
|
||||
// Stats always change (cpu/mem fluctuate)
|
||||
broadcast({ type: "stats", data: stats });
|
||||
} catch (err) {
|
||||
console.error("Poll error:", err);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// ── Start ──
|
||||
const server = Bun.serve({
|
||||
hostname: HOST,
|
||||
port: PORT,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// WebSocket upgrade
|
||||
if (url.pathname === "/ws") {
|
||||
const token = url.searchParams.get("token") || "";
|
||||
if (AUTH_TOKEN && token !== AUTH_TOKEN) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (server.upgrade(req)) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
return app.fetch(req, server);
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
clients.add(ws as unknown as WebSocket);
|
||||
},
|
||||
close(ws) {
|
||||
const native = ws as unknown as WebSocket;
|
||||
cleanupLogStream(native);
|
||||
clients.delete(native);
|
||||
},
|
||||
message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
|
||||
const native = ws as unknown as WebSocket;
|
||||
|
||||
if (msg.type === "subscribe_logs" && msg.container) {
|
||||
// Clean up any existing stream first
|
||||
cleanupLogStream(native);
|
||||
|
||||
const stream = streamContainerLogs(msg.container, (line) => {
|
||||
try {
|
||||
native.send(JSON.stringify({ type: "log_line", data: line }));
|
||||
} catch {}
|
||||
});
|
||||
logStreams.set(native, stream);
|
||||
} else if (msg.type === "unsubscribe_logs") {
|
||||
cleanupLogStream(native);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mode = ALL ? "all projects" : `project(s): ${PROJECTS.join(", ")}`;
|
||||
console.log(`\n Alteonx DockerFlow`);
|
||||
console.log(` → http://${HOST}:${PORT}`);
|
||||
console.log(` → Mode: ${mode}`);
|
||||
console.log(` → Auth: ${AUTH_TOKEN ? "enabled" : "disabled (localhost only)"}\n`);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { docker } from "./docker";
|
||||
import type { Service, Stats, DockerEvent } from "../shared/types";
|
||||
|
||||
export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||
const running = services.filter((s) => s.state === "running");
|
||||
const results: Stats[] = [];
|
||||
|
||||
for (const svc of running) {
|
||||
try {
|
||||
const container = docker.getContainer(svc.id);
|
||||
const raw = await container.stats({ stream: false });
|
||||
|
||||
const cpuDelta =
|
||||
raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
|
||||
const sysDelta =
|
||||
raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
|
||||
|
||||
const cpu =
|
||||
sysDelta > 0
|
||||
? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100
|
||||
: 0;
|
||||
|
||||
const memUsage = raw.memory_stats.usage || 0;
|
||||
const memLimit = raw.memory_stats.limit || 1;
|
||||
|
||||
results.push({
|
||||
service: svc.uid,
|
||||
cpu: parseFloat(cpu.toFixed(2)),
|
||||
mem_mb: parseFloat((memUsage / 1024 / 1024).toFixed(1)),
|
||||
mem_percent: parseFloat(((memUsage / memLimit) * 100).toFixed(1)),
|
||||
});
|
||||
} catch {
|
||||
// Container may have stopped between discovery and stats
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
|
||||
docker.getEvents({}, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
console.error("Failed to watch Docker events:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
try {
|
||||
const event = JSON.parse(chunk.toString());
|
||||
if (event.Type !== "container") return;
|
||||
|
||||
const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
|
||||
if (!["start", "stop", "die", "restart", "health_status"].includes(action)) return;
|
||||
|
||||
const svcName =
|
||||
event.Actor?.Attributes?.["com.docker.compose.service"] ||
|
||||
event.Actor?.Attributes?.name ||
|
||||
"unknown";
|
||||
const svcProject =
|
||||
event.Actor?.Attributes?.["com.docker.compose.project"] ||
|
||||
"standalone";
|
||||
onEvent({
|
||||
type: "docker",
|
||||
action,
|
||||
service: `${svcProject}/${svcName}`,
|
||||
time: event.time || Date.now() / 1000,
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface Service {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: "running" | "exited" | "paused" | "restarting" | "dead";
|
||||
status: string;
|
||||
ports: { host: number; container: number }[];
|
||||
networks: string[];
|
||||
project: string;
|
||||
compose_file: string;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
from: string;
|
||||
to: string;
|
||||
network: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
service: string;
|
||||
cpu: number;
|
||||
mem_mb: number;
|
||||
mem_percent: number;
|
||||
}
|
||||
|
||||
export interface DockerEvent {
|
||||
type: "docker";
|
||||
action: string;
|
||||
service: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
container: string;
|
||||
line: string;
|
||||
timestamp: string;
|
||||
stream: "stdout" | "stderr";
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| { type: "services"; data: Service[] }
|
||||
| { type: "connections"; data: Connection[] }
|
||||
| { type: "stats"; data: Stats[] }
|
||||
| { type: "docker_event"; data: DockerEvent }
|
||||
| { type: "subscribe_logs"; container: string }
|
||||
| { type: "unsubscribe_logs" }
|
||||
| { type: "log_line"; data: LogLine };
|
||||
Reference in New Issue
Block a user