mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
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 };
|
|
}
|