This commit is contained in:
RGJorge
2026-03-22 09:53:08 +00:00
commit 8703f28856
31 changed files with 4543 additions and 0 deletions
+744
View File
@@ -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>
);
}
+358
View File
@@ -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

+138
View File
@@ -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 };
}
+81
View File
@@ -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); }
}
+13
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
+73
View File
@@ -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>
);
});
+214
View File
@@ -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>
);
});
+141
View File
@@ -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

+245
View File
@@ -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();
}
},
};
}
+202
View File
@@ -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`);
+71
View File
@@ -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 {}
});
});
}
+50
View File
@@ -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 };