mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.9
This commit is contained in:
+38
-45
@@ -269,28 +269,30 @@ function Dashboard({ token }: { token: string }) {
|
||||
initialLayoutDone.current = true;
|
||||
} else {
|
||||
setNodes((prev) => {
|
||||
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 newNodeMap = new Map(newNodes.map((n) => [n.id, n]));
|
||||
const prevNodeMap = new Map(prev.map((n) => [n.id, n]));
|
||||
|
||||
const existingIds = new Set(updated.map((n) => n.id));
|
||||
const brand = newNodes.filter((n) => !existingIds.has(n.id));
|
||||
// 1. Update existing nodes (keep position, update data)
|
||||
const result: Node[] = [];
|
||||
for (const nn of newNodes) {
|
||||
const existing = prevNodeMap.get(nn.id);
|
||||
if (existing) {
|
||||
// Keep position and style, update data
|
||||
result.push({ ...existing, data: nn.data });
|
||||
} else {
|
||||
// New node — use saved position if available
|
||||
const saved = savedPositions.current[nn.id];
|
||||
result.push(saved ? { ...nn, position: saved } : nn);
|
||||
}
|
||||
}
|
||||
// Nodes in prev but NOT in newNodes are simply dropped (they disappeared)
|
||||
|
||||
if (brand.length === 0) return updated;
|
||||
|
||||
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;
|
||||
});
|
||||
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;
|
||||
// 2. Resize groups to fit their children
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const n = result[i];
|
||||
if (n.type !== "group") continue;
|
||||
const kids = result.filter((c) => c.parentId === n.id);
|
||||
if (kids.length === 0) continue;
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
for (const k of kids) {
|
||||
@@ -300,30 +302,19 @@ function Dashboard({ token }: { token: string }) {
|
||||
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];
|
||||
result[i] = { ...n, style: { ...n.style, width: newW, height: newH } };
|
||||
}
|
||||
|
||||
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);
|
||||
// 3. Recompute edges
|
||||
const { edges: updatedEdges, activeHandles } = computeEdges(result, filteredConnections);
|
||||
setEdges(updatedEdges);
|
||||
for (const n of result) {
|
||||
if (n.type === "service") {
|
||||
(n.data as any).activeHandles = activeHandles.get(n.id) || [];
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}, [filteredServices, filteredConnections, statsVersion]);
|
||||
@@ -507,22 +498,24 @@ function Dashboard({ token }: { token: string }) {
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.2}
|
||||
maxZoom={2.5}
|
||||
minZoom={0.3}
|
||||
maxZoom={1}
|
||||
panOnScroll={true}
|
||||
translateExtent={[[-1000, -1000], [8000, 6000]]}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
|
||||
<Background color="#1e293b" gap={24} size={1} />
|
||||
<Background color="#374151" gap={30} size={2} />
|
||||
<Controls position="bottom-left" />
|
||||
<EdgeLegend />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
nodeColor={(n) => {
|
||||
if (n.type === "group") return "#1e293b";
|
||||
const state = (n.data as any)?.state;
|
||||
if (state === "running") return "#22c55e";
|
||||
if (state === "exited" || state === "dead") return "#ef4444";
|
||||
return "#f59e0b";
|
||||
if (state === "running") return "#22c55e80";
|
||||
if (state === "exited" || state === "dead") return "#ef444480";
|
||||
return "#f59e0b80";
|
||||
}}
|
||||
style={{ background: "#0f172a" }}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,8 @@ export function useDocker(token = "") {
|
||||
break;
|
||||
case "connections":
|
||||
setConnections((prev) => {
|
||||
if (prev.length === msg.data.length) return prev;
|
||||
if (prev.length === msg.data.length &&
|
||||
prev.every((c: any, i: number) => c.from === msg.data[i].from && c.to === msg.data[i].to)) return prev;
|
||||
return msg.data;
|
||||
});
|
||||
break;
|
||||
|
||||
+31
-12
@@ -15,6 +15,7 @@
|
||||
background-color: #0f172a !important;
|
||||
border: 1px solid #1e293b !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
@@ -27,6 +28,13 @@
|
||||
background-color: #1e293b !important;
|
||||
color: #94a3b8 !important;
|
||||
border-bottom: 1px solid #334155 !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
@@ -34,24 +42,35 @@
|
||||
}
|
||||
|
||||
/* 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-border-green {
|
||||
0% { border-color: #22c55e; box-shadow: 0 0 12px 2px rgba(34, 197, 94, 0.4); }
|
||||
100% { border-color: rgba(51, 65, 85, 0.8); box-shadow: none; }
|
||||
}
|
||||
|
||||
@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-border-red {
|
||||
0% { border-color: #ef4444; box-shadow: 0 0 12px 2px rgba(239, 68, 68, 0.4); }
|
||||
100% { border-color: #ef4444; box-shadow: none; }
|
||||
}
|
||||
|
||||
@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); }
|
||||
@keyframes flash-border-yellow {
|
||||
0% { border-color: #f59e0b; box-shadow: 0 0 12px 2px rgba(245, 158, 11, 0.4); }
|
||||
100% { border-color: rgba(51, 65, 85, 0.8); box-shadow: none; }
|
||||
}
|
||||
|
||||
.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; }
|
||||
.flash-start { animation: flash-border-green 1.2s ease-out; }
|
||||
.flash-stop { animation: flash-border-red 1.2s ease-out forwards; }
|
||||
.flash-restart { animation: flash-border-yellow 1.2s ease-out 2; }
|
||||
|
||||
/* Fade out animation for removed nodes */
|
||||
@keyframes node-fade-out {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.node-removing {
|
||||
animation: node-fade-out 0.8s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Log panel slide-up */
|
||||
@keyframes slideUp {
|
||||
|
||||
@@ -39,12 +39,12 @@ interface ServiceNodeData {
|
||||
[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" },
|
||||
const stateStyles: Record<string, { ring: string; dot: string; bg: string; border: string }> = {
|
||||
running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10", border: "border-slate-700/80" },
|
||||
exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10", border: "border-red-500/60" },
|
||||
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/60" },
|
||||
restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/60" },
|
||||
dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10", border: "border-red-500/60" },
|
||||
};
|
||||
|
||||
// Map image/name patterns to Lucide icons and colors
|
||||
@@ -123,7 +123,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
||||
return (
|
||||
<div
|
||||
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.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
|
||||
className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm
|
||||
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
|
||||
transition-all duration-300 ${flashClass}`}
|
||||
style={particleGlow ? {
|
||||
@@ -153,28 +153,28 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
||||
<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">
|
||||
{/* Top section: icon left, name + image right */}
|
||||
<div className="flex gap-3 mb-2">
|
||||
<div
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg"
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg shrink-0 self-center"
|
||||
style={{ backgroundColor: `${iconColor}22` }}
|
||||
>
|
||||
<Icon size={18} style={{ color: iconColor }} />
|
||||
<Icon size={20} style={{ color: iconColor }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate mt-0.5">
|
||||
{d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex gap-1.5 flex-wrap mb-2">
|
||||
{d.ports.map((p) => (
|
||||
<span
|
||||
key={`${p.host}:${p.container}`}
|
||||
|
||||
+67
-13
@@ -139,21 +139,12 @@ function cleanupLogStream(ws: WebSocket) {
|
||||
}
|
||||
|
||||
// ── Docker events ──
|
||||
watchDockerEvents((event) => {
|
||||
broadcast({ type: "docker_event", data: event });
|
||||
});
|
||||
|
||||
// ── Stats polling ──
|
||||
let lastServicesHash = "";
|
||||
let lastConnectionsHash = "";
|
||||
|
||||
setInterval(async () => {
|
||||
async function refreshServices() {
|
||||
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;
|
||||
@@ -166,12 +157,55 @@ setInterval(async () => {
|
||||
broadcast({ type: "connections", data: connections });
|
||||
}
|
||||
|
||||
// Stats always change (cpu/mem fluctuate)
|
||||
broadcast({ type: "stats", data: stats });
|
||||
} catch (err) {
|
||||
console.error("Poll error:", err);
|
||||
console.error("Refresh error:", err);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Quick refresh — services + connections, no stats (fast)
|
||||
async function quickRefresh() {
|
||||
try {
|
||||
const services = await discoverServices(ALL, PROJECTS);
|
||||
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
|
||||
if (svcHash !== lastServicesHash) {
|
||||
lastServicesHash = svcHash;
|
||||
broadcast({ type: "services", data: services });
|
||||
|
||||
// Also refresh connections when services change
|
||||
const connections = await discoverConnections(services);
|
||||
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
|
||||
if (connHash !== lastConnectionsHash) {
|
||||
lastConnectionsHash = connHash;
|
||||
broadcast({ type: "connections", data: connections });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Debounced refresh for Docker events
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(refreshTimer);
|
||||
clearTimeout(retryTimer);
|
||||
// First check at 1.5s, retry at 3.5s to catch stragglers (e.g. slow destroy)
|
||||
refreshTimer = setTimeout(() => {
|
||||
quickRefresh();
|
||||
retryTimer = setTimeout(quickRefresh, 2000);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
watchDockerEvents((event) => {
|
||||
broadcast({ type: "docker_event", data: event });
|
||||
scheduleRefresh();
|
||||
});
|
||||
|
||||
// ── Stats polling ──
|
||||
let lastServicesHash = "";
|
||||
let lastConnectionsHash = "";
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_MS);
|
||||
|
||||
// ── Start ──
|
||||
const server = Bun.serve({
|
||||
@@ -199,6 +233,16 @@ const server = Bun.serve({
|
||||
if (flowsData.flows.length > 0) {
|
||||
try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {}
|
||||
}
|
||||
// Send current services/connections/stats
|
||||
discoverServices(ALL, PROJECTS).then(async (services) => {
|
||||
const connections = await discoverConnections(services);
|
||||
const stats = await pollStats(services);
|
||||
try {
|
||||
native.send(JSON.stringify({ type: "services", data: services }));
|
||||
native.send(JSON.stringify({ type: "connections", data: connections }));
|
||||
native.send(JSON.stringify({ type: "stats", data: stats }));
|
||||
} catch {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
@@ -222,6 +266,16 @@ const server = Bun.serve({
|
||||
if (flowsData.flows.length > 0) {
|
||||
native.send(JSON.stringify({ type: "flows", data: flowsData }));
|
||||
}
|
||||
// Send current services/connections/stats immediately
|
||||
discoverServices(ALL, PROJECTS).then(async (services) => {
|
||||
const connections = await discoverConnections(services);
|
||||
const stats = await pollStats(services);
|
||||
try {
|
||||
native.send(JSON.stringify({ type: "services", data: services }));
|
||||
native.send(JSON.stringify({ type: "connections", data: connections }));
|
||||
native.send(JSON.stringify({ type: "stats", data: stats }));
|
||||
} catch {}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
native.send(JSON.stringify({ type: "auth_error" }));
|
||||
native.close();
|
||||
|
||||
@@ -60,7 +60,7 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
|
||||
if (event.Type !== "container") continue;
|
||||
|
||||
const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
|
||||
if (!["start", "stop", "die", "restart", "health_status"].includes(action)) continue;
|
||||
if (!["start", "stop", "die", "restart", "destroy", "create", "health_status"].includes(action)) continue;
|
||||
|
||||
const svcName =
|
||||
event.Actor?.Attributes?.["com.docker.compose.service"] ||
|
||||
|
||||
Reference in New Issue
Block a user