This commit is contained in:
RGJorge
2026-05-01 04:55:52 +00:00
parent cb25260627
commit c6da792e4b
6 changed files with 169 additions and 102 deletions
+47 -54
View File
@@ -269,61 +269,52 @@ 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;
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];
// 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) {
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);
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" }}
/>
+2 -1
View File
@@ -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
View File
@@ -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 {
+21 -21
View File
@@ -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}`} />
</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>
<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">
<div className="flex gap-1.5 flex-wrap mb-2">
{d.ports.map((p) => (
<span
key={`${p.host}:${p.container}`}
+67 -13
View File
@@ -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();
+1 -1
View File
@@ -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"] ||