@@ -132,12 +160,9 @@ export default function ServiceFlowDiagram({
const target = parts[1];
if (!source || !target) {
- console.warn('Invalid edge ID:', edgeId);
return;
}
- console.log(`Creating edge from ${source} to ${target} with networks:`, networks);
-
edges.push({
id: edgeId,
source: source,
@@ -149,8 +174,9 @@ export default function ServiceFlowDiagram({
selectable: false,
focusable: false,
style: {
- stroke: isDark ? 'oklch(0.488 0.243 264.376)' : 'oklch(0.205 0 0)',
+ stroke: isDark ? 'oklch(0.556 0 0)' : 'oklch(0.556 0 0)',
strokeWidth: 2,
+ strokeDasharray: '5,5',
},
labelStyle: {
fill: isDark ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)',
@@ -164,12 +190,9 @@ export default function ServiceFlowDiagram({
});
});
- console.log('ServiceFlowDiagram - networkToServices:', Array.from(networkToServices.entries()));
- console.log('ServiceFlowDiagram - edgeMap:', Array.from(edgeMap.entries()));
- console.log('ServiceFlowDiagram - Created edges:', edges.length, edges);
- console.log('ServiceFlowDiagram - Created nodes:', nodes.length, nodes.map(n => ({ id: n.id, name: nodes.find(node => node.id === n.id)?.data.label })));
-
- return { nodes, edges };
+ // Apply automatic layout based on edges
+ const layouted = getLayoutedElements(nodes, edges);
+ return layouted;
}, [services, isDark]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
diff --git a/apps/web/package.json b/apps/web/package.json
index 1cbc30e..4881568 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -46,6 +46,7 @@
"bullmq": "^5.65.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dagre": "^0.8.5",
"ioredis": "^5.8.2",
"lucide-react": "^0.548.0",
"next": "^16.0.6",
@@ -68,6 +69,7 @@
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
+ "@types/dagre": "^0.7.53",
"@types/node": "^22.19.1",
"@types/react": "19.2.0",
"@types/react-dom": "19.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index afe3a52..0737272 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -207,6 +207,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ dagre:
+ specifier: ^0.8.5
+ version: 0.8.5
ioredis:
specifier: ^5.8.2
version: 5.8.2
@@ -268,6 +271,9 @@ importers:
'@repo/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
+ '@types/dagre':
+ specifier: ^0.7.53
+ version: 0.7.53
'@types/node':
specifier: ^22.19.1
version: 22.19.1
@@ -2697,6 +2703,9 @@ packages:
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+ '@types/dagre@0.7.53':
+ resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==}
+
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -3398,6 +3407,9 @@ packages:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
+ dagre@0.8.5:
+ resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -3938,6 +3950,9 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+ graphlib@2.1.8:
+ resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
+
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
@@ -8192,6 +8207,8 @@ snapshots:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
+ '@types/dagre@0.7.53': {}
+
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
@@ -8936,6 +8953,11 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
+ dagre@0.8.5:
+ dependencies:
+ graphlib: 2.1.8
+ lodash: 4.17.21
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -9655,6 +9677,10 @@ snapshots:
graphemer@1.4.0: {}
+ graphlib@2.1.8:
+ dependencies:
+ lodash: 4.17.21
+
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
From e7e13e68e12dfa3bb6787be0825ab7cc805f8c7c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 02:52:48 +0000
Subject: [PATCH 08/15] Fix build error with undefined check and improve layout
to prevent edge overlaps
Co-authored-by: SimonLoir <17649337+SimonLoir@users.noreply.github.com>
---
.../components/service-flow-diagram.tsx | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index c8a66b6..5a46a09 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -30,7 +30,16 @@ type ServiceFlowDiagramProps = {
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
- dagreGraph.setGraph({ rankdir: 'LR', ranksep: 150, nodesep: 100 });
+
+ // Configure graph with increased spacing to prevent overlaps
+ dagreGraph.setGraph({
+ rankdir: 'TB', // Top to bottom for better vertical spacing
+ ranksep: 200, // Increased spacing between ranks
+ nodesep: 150, // Increased spacing between nodes
+ edgesep: 50, // Spacing between edges
+ marginx: 50, // Margin on x-axis
+ marginy: 50, // Margin on y-axis
+ });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: 300, height: 150 });
@@ -139,6 +148,8 @@ export default function ServiceFlowDiagram({
const sourceService = servicesInNetwork[i];
const targetService = servicesInNetwork[j];
+ if (!sourceService || !targetService) continue;
+
const sourceId = sourceService.id;
const targetId = targetService.id;
const edgeId = [sourceId, targetId].sort().join('|||');
From 5de70196554e7e18f88c90516678837490a6b84e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 11:23:01 +0000
Subject: [PATCH 09/15] Create network nodes and connect services to networks
instead of service-to-service connections
Co-authored-by: SimonLoir <17649337+SimonLoir@users.noreply.github.com>
---
.../components/service-flow-diagram.tsx | 156 +++++++++---------
1 file changed, 74 insertions(+), 82 deletions(-)
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index 5a46a09..a2d676b 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -33,16 +33,21 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
// Configure graph with increased spacing to prevent overlaps
dagreGraph.setGraph({
- rankdir: 'TB', // Top to bottom for better vertical spacing
+ rankdir: 'LR', // Left to right for bipartite graph (services -> networks)
ranksep: 200, // Increased spacing between ranks
- nodesep: 150, // Increased spacing between nodes
- edgesep: 50, // Spacing between edges
+ nodesep: 100, // Spacing between nodes
+ edgesep: 30, // Spacing between edges
marginx: 50, // Margin on x-axis
marginy: 50, // Margin on y-axis
});
nodes.forEach((node) => {
- dagreGraph.setNode(node.id, { width: 300, height: 150 });
+ // Different sizes for service and network nodes
+ const isNetworkNode = node.id.startsWith('network-');
+ dagreGraph.setNode(node.id, {
+ width: isNetworkNode ? 200 : 300,
+ height: isNetworkNode ? 80 : 150
+ });
});
edges.forEach((edge) => {
@@ -53,11 +58,12 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
+ const isNetworkNode = node.id.startsWith('network-');
return {
...node,
position: {
- x: nodeWithPosition.x - 150,
- y: nodeWithPosition.y - 75,
+ x: nodeWithPosition.x - (isNetworkNode ? 100 : 150),
+ y: nodeWithPosition.y - (isNetworkNode ? 40 : 75),
},
};
});
@@ -74,24 +80,23 @@ export default function ServiceFlowDiagram({
// Create nodes and edges from services
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
- // Group services by networks
- const networkToServices = new Map
();
+ // Collect all unique networks
+ const networksMap = new Map();
services.forEach((service) => {
service.networks.forEach((network) => {
const networkId = String(network.id);
- if (!networkToServices.has(networkId)) {
- networkToServices.set(networkId, []);
- }
- const servicesArray = networkToServices.get(networkId);
- if (servicesArray) {
- servicesArray.push(service);
+ if (!networksMap.has(networkId)) {
+ networksMap.set(networkId, {
+ id: networkId,
+ name: String(network.name),
+ });
}
});
});
- // Create nodes for services (initial positioning will be updated by layout)
- const nodes: Node[] = services.map((service) => {
+ // Create service nodes
+ const serviceNodes: Node[] = services.map((service) => {
return {
id: service.id,
type: 'default',
@@ -131,73 +136,57 @@ export default function ServiceFlowDiagram({
};
});
- // Create edges based on shared networks
- const edges: Edge[] = [];
- const edgeMap = new Map();
-
- networkToServices.forEach((servicesInNetwork, networkId) => {
- const network = services
- .flatMap((s) => s.networks)
- .find((n) => String(n.id) === networkId);
-
- if (!network || servicesInNetwork.length < 2) return;
-
- // Connect all services in the same network
- for (let i = 0; i < servicesInNetwork.length; i++) {
- for (let j = i + 1; j < servicesInNetwork.length; j++) {
- const sourceService = servicesInNetwork[i];
- const targetService = servicesInNetwork[j];
-
- if (!sourceService || !targetService) continue;
-
- const sourceId = sourceService.id;
- const targetId = targetService.id;
- const edgeId = [sourceId, targetId].sort().join('|||');
-
- if (!edgeMap.has(edgeId)) {
- edgeMap.set(edgeId, { networks: [] });
- }
- const edgeData = edgeMap.get(edgeId);
- if (edgeData) {
- edgeData.networks.push(String(network.name));
- }
- }
- }
- });
-
- edgeMap.forEach(({ networks }, edgeId) => {
- const parts = edgeId.split('|||');
- const source = parts[0];
- const target = parts[1];
-
- if (!source || !target) {
- return;
- }
-
- edges.push({
- id: edgeId,
- source: source,
- target: target,
- label: networks.join(', '),
- type: 'smoothstep',
- animated: false,
- deletable: false,
- selectable: false,
- focusable: false,
- style: {
- stroke: isDark ? 'oklch(0.556 0 0)' : 'oklch(0.556 0 0)',
- strokeWidth: 2,
- strokeDasharray: '5,5',
- },
- labelStyle: {
- fill: isDark ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)',
- fontSize: 12,
- fontWeight: 500,
+ // Create network nodes
+ const networkNodes: Node[] = Array.from(networksMap.values()).map((network) => {
+ return {
+ id: `network-${network.id}`,
+ type: 'default',
+ position: { x: 0, y: 0 }, // Will be set by dagre layout
+ data: {
+ label: (
+
+ ),
},
- labelBgStyle: {
- fill: isDark ? 'oklch(0.205 0 0)' : 'oklch(1 0 0)',
- fillOpacity: 0.9,
+ style: {
+ background: isDark ? 'oklch(0.269 0 0)' : 'oklch(0.97 0 0)',
+ border: `2px solid ${isDark ? 'oklch(0.488 0.243 264.376)' : 'oklch(0.646 0.222 41.116)'}`,
+ borderRadius: '0.625rem',
+ padding: 0,
+ width: 200,
+ cursor: 'default',
},
+ };
+ });
+
+ const nodes = [...serviceNodes, ...networkNodes];
+
+ // Create edges connecting services to networks
+ const edges: Edge[] = [];
+
+ services.forEach((service) => {
+ service.networks.forEach((network) => {
+ const networkId = String(network.id);
+ const edgeId = `${service.id}|||network-${networkId}`;
+
+ edges.push({
+ id: edgeId,
+ source: service.id,
+ target: `network-${networkId}`,
+ type: 'smoothstep',
+ animated: false,
+ deletable: false,
+ selectable: false,
+ focusable: false,
+ style: {
+ stroke: isDark ? 'oklch(0.556 0 0)' : 'oklch(0.556 0 0)',
+ strokeWidth: 2,
+ strokeDasharray: '5,5',
+ },
+ });
});
});
@@ -217,7 +206,10 @@ export default function ServiceFlowDiagram({
const onNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
- router.push(`/dashboard/services/${node.id}`);
+ // Only navigate for service nodes, not network nodes
+ if (!node.id.startsWith('network-')) {
+ router.push(`/dashboard/services/${node.id}`);
+ }
},
[router]
);
From e216e8f99981e161ac179e63eb04ff0e89da83c7 Mon Sep 17 00:00:00 2001
From: SimonLoir
Date: Fri, 5 Dec 2025 12:46:34 +0100
Subject: [PATCH 10/15] chore: enhance service flow diagram and improve code
consistency
- Added `ConnectionMode.Loose` to ReactFlow configuration for flexible edge connections.
- Enabled edge animations and improved styling for better visualization.
- Standardized CSS classes to single quotes for consistency.
- Refactored layout generation to enhance readability and maintainability.
- Improved graph spacing and node dimensions for optimized presentation.
---
.../components/service-flow-diagram.tsx | 111 ++++++++++--------
1 file changed, 61 insertions(+), 50 deletions(-)
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index a2d676b..275cf9b 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -8,6 +8,7 @@ import {
Edge,
useNodesState,
useEdgesState,
+ ConnectionMode,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useRouter } from 'next/navigation';
@@ -30,23 +31,23 @@ type ServiceFlowDiagramProps = {
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
-
+
// Configure graph with increased spacing to prevent overlaps
- dagreGraph.setGraph({
+ dagreGraph.setGraph({
rankdir: 'LR', // Left to right for bipartite graph (services -> networks)
- ranksep: 200, // Increased spacing between ranks
- nodesep: 100, // Spacing between nodes
- edgesep: 30, // Spacing between edges
- marginx: 50, // Margin on x-axis
- marginy: 50, // Margin on y-axis
+ ranksep: 200, // Increased spacing between ranks
+ nodesep: 100, // Spacing between nodes
+ edgesep: 30, // Spacing between edges
+ marginx: 50, // Margin on x-axis
+ marginy: 50, // Margin on y-axis
});
nodes.forEach((node) => {
// Different sizes for service and network nodes
const isNetworkNode = node.id.startsWith('network-');
- dagreGraph.setNode(node.id, {
- width: isNetworkNode ? 200 : 300,
- height: isNetworkNode ? 80 : 150
+ dagreGraph.setNode(node.id, {
+ width: isNetworkNode ? 200 : 300,
+ height: isNetworkNode ? 80 : 150,
});
});
@@ -82,7 +83,7 @@ export default function ServiceFlowDiagram({
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
// Collect all unique networks
const networksMap = new Map();
-
+
services.forEach((service) => {
service.networks.forEach((network) => {
const networkId = String(network.id);
@@ -103,22 +104,24 @@ export default function ServiceFlowDiagram({
position: { x: 0, y: 0 }, // Will be set by dagre layout
data: {
label: (
-
-
-
+
+
+
{service.name}
e.stopPropagation()}>
-
+
{service.description && (
-
+
{service.description}
)}
-
-
+
+
{service.server.name}
@@ -137,52 +140,58 @@ export default function ServiceFlowDiagram({
});
// Create network nodes
- const networkNodes: Node[] = Array.from(networksMap.values()).map((network) => {
- return {
- id: `network-${network.id}`,
- type: 'default',
- position: { x: 0, y: 0 }, // Will be set by dagre layout
- data: {
- label: (
-
-
- {network.name}
+ const networkNodes: Node[] = Array.from(networksMap.values()).map(
+ (network) => {
+ return {
+ id: `network-${network.id}`,
+ type: 'default',
+ position: { x: 0, y: 0 }, // Will be set by dagre layout
+ data: {
+ label: (
+
-
- ),
- },
- style: {
- background: isDark ? 'oklch(0.269 0 0)' : 'oklch(0.97 0 0)',
- border: `2px solid ${isDark ? 'oklch(0.488 0.243 264.376)' : 'oklch(0.646 0.222 41.116)'}`,
- borderRadius: '0.625rem',
- padding: 0,
- width: 200,
- cursor: 'default',
- },
- };
- });
+ ),
+ },
+ style: {
+ background: isDark
+ ? 'oklch(0.269 0 0)'
+ : 'oklch(0.97 0 0)',
+ border: `2px solid ${isDark ? 'oklch(0.488 0.243 264.376)' : 'oklch(0.646 0.222 41.116)'}`,
+ borderRadius: '0.625rem',
+ padding: 0,
+ width: 200,
+ cursor: 'default',
+ },
+ };
+ }
+ );
const nodes = [...serviceNodes, ...networkNodes];
// Create edges connecting services to networks
const edges: Edge[] = [];
-
+
services.forEach((service) => {
service.networks.forEach((network) => {
const networkId = String(network.id);
const edgeId = `${service.id}|||network-${networkId}`;
-
+
edges.push({
id: edgeId,
source: service.id,
target: `network-${networkId}`,
type: 'smoothstep',
- animated: false,
+ animated: true,
deletable: false,
selectable: false,
focusable: false,
style: {
- stroke: isDark ? 'oklch(0.556 0 0)' : 'oklch(0.556 0 0)',
+ stroke: isDark
+ ? 'oklch(0.556 0 0)'
+ : 'oklch(0.556 0 0)',
strokeWidth: 2,
strokeDasharray: '5,5',
},
@@ -216,14 +225,14 @@ export default function ServiceFlowDiagram({
if (services.length === 0) {
return (
-
+
No services found in this environment
);
}
return (
-
+
From 72f4f858e5e9491169c1b919043d1e1ef0b4a3be Mon Sep 17 00:00:00 2001
From: SimonLoir
Date: Fri, 5 Dec 2025 13:26:39 +0100
Subject: [PATCH 11/15] chore: enhance service flow diagram and refactor nodes
rendering
- Updated node and network rendering to use `Card` components for improved UI consistency.
- Introduced `service` and `network` node types with enhanced styling and interaction capabilities.
- Adjusted graph layout parameters for better spacing and alignment.
- Refactored edge rendering to use `bezier` for smoother connections.
- Improved `ServiceFlowDiagram` component structure, simplifying layout logic.
- Updated `env-services-page` to wrap content in a structured container (`div` with `space-y-4`).
---
.../components/env-services-page.tsx | 4 +-
.../components/service-flow-diagram.tsx | 147 +++++++++++-------
2 files changed, 89 insertions(+), 62 deletions(-)
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/env-services-page.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/env-services-page.tsx
index 0e44fcb..95ee3ca 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/env-services-page.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/env-services-page.tsx
@@ -26,7 +26,7 @@ export default function EnvServicesPage({
);
if (!services || !env) return ;
return (
- <>
+
- >
+
);
}
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index 275cf9b..0855244 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -9,6 +9,8 @@ import {
useNodesState,
useEdgesState,
ConnectionMode,
+ Position,
+ Handle,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useRouter } from 'next/navigation';
@@ -18,6 +20,14 @@ import ServiceSettingsDropdown from './service-settings-dropdown';
import { inferProcedureOutput } from '@trpc/server';
import { appRouter } from '@repo/api';
import dagre from 'dagre';
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
type Service = inferProcedureOutput<
typeof appRouter.services.listServices
@@ -36,10 +46,10 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
dagreGraph.setGraph({
rankdir: 'LR', // Left to right for bipartite graph (services -> networks)
ranksep: 200, // Increased spacing between ranks
- nodesep: 100, // Spacing between nodes
- edgesep: 30, // Spacing between edges
- marginx: 50, // Margin on x-axis
- marginy: 50, // Margin on y-axis
+ nodesep: 50, // Spacing between nodes
+ edgesep: 100, // Spacing between edges
+ marginx: 0, // Margin on x-axis
+ marginy: 0, // Margin on y-axis
});
nodes.forEach((node) => {
@@ -100,42 +110,11 @@ export default function ServiceFlowDiagram({
const serviceNodes: Node[] = services.map((service) => {
return {
id: service.id,
- type: 'default',
- position: { x: 0, y: 0 }, // Will be set by dagre layout
+ type: 'service',
data: {
- label: (
-
-
-
- {service.name}
-
-
e.stopPropagation()}>
-
-
-
- {service.description && (
-
- {service.description}
-
- )}
-
-
- {service.server.name}
-
-
-
- ),
- },
- style: {
- background: isDark ? 'oklch(0.205 0 0)' : 'oklch(1 0 0)',
- border: `1px solid ${isDark ? 'oklch(1 0 0 / 10%)' : 'oklch(0.922 0 0)'}`,
- borderRadius: '0.625rem',
- padding: 0,
- width: 300,
- cursor: 'pointer',
+ service,
},
+ position: { x: 0, y: 0 }, // Will be set by dagre layout
};
});
@@ -144,26 +123,10 @@ export default function ServiceFlowDiagram({
(network) => {
return {
id: `network-${network.id}`,
- type: 'default',
+ type: 'network',
position: { x: 0, y: 0 }, // Will be set by dagre layout
data: {
- label: (
-
- ),
- },
- style: {
- background: isDark
- ? 'oklch(0.269 0 0)'
- : 'oklch(0.97 0 0)',
- border: `2px solid ${isDark ? 'oklch(0.488 0.243 264.376)' : 'oklch(0.646 0.222 41.116)'}`,
- borderRadius: '0.625rem',
- padding: 0,
- width: 200,
- cursor: 'default',
+ network,
},
};
}
@@ -183,7 +146,7 @@ export default function ServiceFlowDiagram({
id: edgeId,
source: service.id,
target: `network-${networkId}`,
- type: 'smoothstep',
+ type: 'bezier',
animated: true,
deletable: false,
selectable: false,
@@ -200,8 +163,7 @@ export default function ServiceFlowDiagram({
});
// Apply automatic layout based on edges
- const layouted = getLayoutedElements(nodes, edges);
- return layouted;
+ return getLayoutedElements(nodes, edges);
}, [services, isDark]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
@@ -240,12 +202,76 @@ export default function ServiceFlowDiagram({
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
fitView
- attributionPosition='bottom-left'
+ attributionPosition='bottom-right'
edgesFocusable={false}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
connectionMode={ConnectionMode.Loose}
+ nodeTypes={{
+ service: ({
+ data: { service },
+ isConnectable,
+ }: Readonly<{
+ data: { service: Service };
+ isConnectable: boolean;
+ }>) => (
+ <>
+
+
+
+
+ {service.name}
+ {service.description && (
+
+ {service.description}
+
+ )}
+ e.stopPropagation()}
+ >
+
+
+
+
+
+ {service.server.name}
+
+
+
+ >
+ ),
+ network: ({
+ data: { network },
+ isConnectable,
+ }: Readonly<{
+ data: { network: Service['networks'][number] };
+ isConnectable: boolean;
+ }>) => (
+ <>
+
+
+
+
+ {network.name}
+
+
+ >
+ ),
+ }}
>
From 4dc7f68f4f3c4f9afd04fc0dd08ee47ee2678ff3 Mon Sep 17 00:00:00 2001
From: SimonLoir
Date: Fri, 5 Dec 2025 13:33:36 +0100
Subject: [PATCH 12/15] chore: remove unused margin properties from
ServiceFlowDiagram configuration
---
.../[deploymentEnvId]/components/service-flow-diagram.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index 0855244..745e80f 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -48,8 +48,6 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
ranksep: 200, // Increased spacing between ranks
nodesep: 50, // Spacing between nodes
edgesep: 100, // Spacing between edges
- marginx: 0, // Margin on x-axis
- marginy: 0, // Margin on y-axis
});
nodes.forEach((node) => {
From a7e8d8f0e343068dee00bdd0909decc11ca689a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 17:27:52 +0000
Subject: [PATCH 13/15] Refactor: Extract components and utils into separate
files, fix network node handle type
Co-authored-by: SimonLoir <17649337+SimonLoir@users.noreply.github.com>
---
.../components/flow-utils.ts | 54 +++++++
.../components/network-node.tsx | 34 +++++
.../components/service-flow-diagram.tsx | 140 ++----------------
.../components/service-node.tsx | 65 ++++++++
4 files changed, 168 insertions(+), 125 deletions(-)
create mode 100644 apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts
create mode 100644 apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx
create mode 100644 apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-node.tsx
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts
new file mode 100644
index 0000000..0c876b1
--- /dev/null
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts
@@ -0,0 +1,54 @@
+import dagre from 'dagre';
+import { Node, Edge } from '@xyflow/react';
+
+/**
+ * Calculate automatic layout using Dagre for a bipartite graph.
+ * Services are positioned on the left, networks on the right.
+ *
+ * @param nodes - Array of React Flow nodes (services and networks)
+ * @param edges - Array of React Flow edges connecting services to networks
+ * @returns Object containing layouted nodes and original edges
+ */
+export const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
+ const dagreGraph = new dagre.graphlib.Graph();
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+ // Configure graph with increased spacing to prevent overlaps
+ dagreGraph.setGraph({
+ rankdir: 'LR', // Left to right for bipartite graph (services -> networks)
+ ranksep: 200, // Increased spacing between ranks
+ nodesep: 100, // Spacing between nodes
+ edgesep: 30, // Spacing between edges
+ marginx: 50, // Margin on x-axis
+ marginy: 50, // Margin on y-axis
+ });
+
+ nodes.forEach((node) => {
+ // Different sizes for service and network nodes
+ const isNetworkNode = node.id.startsWith('network-');
+ dagreGraph.setNode(node.id, {
+ width: isNetworkNode ? 200 : 300,
+ height: isNetworkNode ? 80 : 150,
+ });
+ });
+
+ edges.forEach((edge) => {
+ dagreGraph.setEdge(edge.source, edge.target);
+ });
+
+ dagre.layout(dagreGraph);
+
+ const layoutedNodes = nodes.map((node) => {
+ const nodeWithPosition = dagreGraph.node(node.id);
+ const isNetworkNode = node.id.startsWith('network-');
+ return {
+ ...node,
+ position: {
+ x: nodeWithPosition.x - (isNetworkNode ? 100 : 150),
+ y: nodeWithPosition.y - (isNetworkNode ? 40 : 75),
+ },
+ };
+ });
+
+ return { nodes: layoutedNodes, edges };
+};
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx
new file mode 100644
index 0000000..9e0558f
--- /dev/null
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx
@@ -0,0 +1,34 @@
+import { Handle, Position } from '@xyflow/react';
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+
+type NetworkNodeProps = Readonly<{
+ data: { network: { id: string; name: string } };
+ isConnectable: boolean;
+}>;
+
+/**
+ * Network node component for the React Flow diagram.
+ * Displays network name as a smaller card on the right side of the graph.
+ * Has a target handle on the left for receiving connections from service nodes.
+ */
+export function NetworkNode({ data: { network }, isConnectable }: NetworkNodeProps) {
+ return (
+ <>
+
+
+
+
+ {network.name}
+
+
+ >
+ );
+}
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index 745e80f..aa6184b 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -9,25 +9,15 @@ import {
useNodesState,
useEdgesState,
ConnectionMode,
- Position,
- Handle,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useRouter } from 'next/navigation';
-import { Badge } from '@/components/ui/badge';
import { useTheme } from 'next-themes';
-import ServiceSettingsDropdown from './service-settings-dropdown';
import { inferProcedureOutput } from '@trpc/server';
import { appRouter } from '@repo/api';
-import dagre from 'dagre';
-import {
- Card,
- CardAction,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
+import { getLayoutedElements } from './flow-utils';
+import { ServiceNode } from './service-node';
+import { NetworkNode } from './network-node';
type Service = inferProcedureOutput<
typeof appRouter.services.listServices
@@ -37,49 +27,11 @@ type ServiceFlowDiagramProps = {
readonly services: Service[];
};
-// Function to calculate automatic layout using Dagre
-const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
- const dagreGraph = new dagre.graphlib.Graph();
- dagreGraph.setDefaultEdgeLabel(() => ({}));
-
- // Configure graph with increased spacing to prevent overlaps
- dagreGraph.setGraph({
- rankdir: 'LR', // Left to right for bipartite graph (services -> networks)
- ranksep: 200, // Increased spacing between ranks
- nodesep: 50, // Spacing between nodes
- edgesep: 100, // Spacing between edges
- });
-
- nodes.forEach((node) => {
- // Different sizes for service and network nodes
- const isNetworkNode = node.id.startsWith('network-');
- dagreGraph.setNode(node.id, {
- width: isNetworkNode ? 200 : 300,
- height: isNetworkNode ? 80 : 150,
- });
- });
-
- edges.forEach((edge) => {
- dagreGraph.setEdge(edge.source, edge.target);
- });
-
- dagre.layout(dagreGraph);
-
- const layoutedNodes = nodes.map((node) => {
- const nodeWithPosition = dagreGraph.node(node.id);
- const isNetworkNode = node.id.startsWith('network-');
- return {
- ...node,
- position: {
- x: nodeWithPosition.x - (isNetworkNode ? 100 : 150),
- y: nodeWithPosition.y - (isNetworkNode ? 40 : 75),
- },
- };
- });
-
- return { nodes: layoutedNodes, edges };
-};
-
+/**
+ * ServiceFlowDiagram component displays services and networks as a bipartite graph.
+ * Services are shown on the left, networks on the right, with edges connecting
+ * services to their associated networks. Uses Dagre for automatic layout.
+ */
export default function ServiceFlowDiagram({
services,
}: ServiceFlowDiagramProps) {
@@ -144,15 +96,13 @@ export default function ServiceFlowDiagram({
id: edgeId,
source: service.id,
target: `network-${networkId}`,
- type: 'bezier',
- animated: true,
+ type: 'smoothstep',
+ animated: false,
deletable: false,
selectable: false,
focusable: false,
style: {
- stroke: isDark
- ? 'oklch(0.556 0 0)'
- : 'oklch(0.556 0 0)',
+ stroke: 'oklch(0.556 0 0)',
strokeWidth: 2,
strokeDasharray: '5,5',
},
@@ -162,12 +112,12 @@ export default function ServiceFlowDiagram({
// Apply automatic layout based on edges
return getLayoutedElements(nodes, edges);
- }, [services, isDark]);
+ }, [services]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
- // Update nodes and edges when theme changes
+ // Update nodes and edges when they change
useEffect(() => {
setNodes(initialNodes);
setEdges(initialEdges);
@@ -207,68 +157,8 @@ export default function ServiceFlowDiagram({
elementsSelectable={true}
connectionMode={ConnectionMode.Loose}
nodeTypes={{
- service: ({
- data: { service },
- isConnectable,
- }: Readonly<{
- data: { service: Service };
- isConnectable: boolean;
- }>) => (
- <>
-
-
-
-
- {service.name}
- {service.description && (
-
- {service.description}
-
- )}
- e.stopPropagation()}
- >
-
-
-
-
-
- {service.server.name}
-
-
-
- >
- ),
- network: ({
- data: { network },
- isConnectable,
- }: Readonly<{
- data: { network: Service['networks'][number] };
- isConnectable: boolean;
- }>) => (
- <>
-
-
-
-
- {network.name}
-
-
- >
- ),
+ service: ServiceNode,
+ network: NetworkNode,
}}
>
[number];
+
+type ServiceNodeProps = Readonly<{
+ data: { service: Service };
+ isConnectable: boolean;
+}>;
+
+/**
+ * Service node component for the React Flow diagram.
+ * Displays service information including name, description, and server badge.
+ * Has a source handle on the right for connecting to network nodes.
+ */
+export function ServiceNode({ data: { service }, isConnectable }: ServiceNodeProps) {
+ return (
+ <>
+
+
+
+
+ {service.name}
+ {service.description && (
+
+ {service.description}
+
+ )}
+ e.stopPropagation()}
+ >
+
+
+
+
+
+ {service.server.name}
+
+
+
+ >
+ );
+}
From 951872e224ce03ef28b77fbaca3048013d2d129a Mon Sep 17 00:00:00 2001
From: Simon Loir
Date: Fri, 5 Dec 2025 19:00:50 +0100
Subject: [PATCH 14/15] fix: use "as" instead of type inference
---
.../components/flow-utils.ts | 19 ++++++++-----------
.../components/network-node.tsx | 18 ++++++++++--------
.../components/service-flow-diagram.tsx | 4 ++--
3 files changed, 20 insertions(+), 21 deletions(-)
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts
index 0c876b1..bbe291e 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/flow-utils.ts
@@ -1,10 +1,10 @@
import dagre from 'dagre';
-import { Node, Edge } from '@xyflow/react';
+import type { Node, Edge } from '@xyflow/react';
/**
* Calculate automatic layout using Dagre for a bipartite graph.
* Services are positioned on the left, networks on the right.
- *
+ *
* @param nodes - Array of React Flow nodes (services and networks)
* @param edges - Array of React Flow edges connecting services to networks
* @returns Object containing layouted nodes and original edges
@@ -17,18 +17,16 @@ export const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
dagreGraph.setGraph({
rankdir: 'LR', // Left to right for bipartite graph (services -> networks)
ranksep: 200, // Increased spacing between ranks
- nodesep: 100, // Spacing between nodes
+ nodesep: 55, // Spacing between nodes
edgesep: 30, // Spacing between edges
marginx: 50, // Margin on x-axis
marginy: 50, // Margin on y-axis
});
nodes.forEach((node) => {
- // Different sizes for service and network nodes
- const isNetworkNode = node.id.startsWith('network-');
dagreGraph.setNode(node.id, {
- width: isNetworkNode ? 200 : 300,
- height: isNetworkNode ? 80 : 150,
+ width: 300,
+ height: 150,
});
});
@@ -40,15 +38,14 @@ export const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
- const isNetworkNode = node.id.startsWith('network-');
return {
...node,
position: {
- x: nodeWithPosition.x - (isNetworkNode ? 100 : 150),
- y: nodeWithPosition.y - (isNetworkNode ? 40 : 75),
+ x: nodeWithPosition.x - 150,
+ y: nodeWithPosition.y - 75,
},
};
});
- return { nodes: layoutedNodes, edges };
+ return { nodes: layoutedNodes as Node[], edges };
};
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx
index 9e0558f..6761842 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/network-node.tsx
@@ -1,9 +1,6 @@
import { Handle, Position } from '@xyflow/react';
-import {
- Card,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
+import { Card, CardHeader, CardTitle } from '@/components/ui/card';
+import { NetworkIcon } from 'lucide-react';
type NetworkNodeProps = Readonly<{
data: { network: { id: string; name: string } };
@@ -15,7 +12,10 @@ type NetworkNodeProps = Readonly<{
* Displays network name as a smaller card on the right side of the graph.
* Has a target handle on the left for receiving connections from service nodes.
*/
-export function NetworkNode({ data: { network }, isConnectable }: NetworkNodeProps) {
+export function NetworkNode({
+ data: { network },
+ isConnectable,
+}: NetworkNodeProps) {
return (
<>
-
+
- {network.name}
+
+ {network.name}
+
>
diff --git a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
index aa6184b..1831b9d 100644
--- a/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
+++ b/apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
@@ -96,8 +96,8 @@ export default function ServiceFlowDiagram({
id: edgeId,
source: service.id,
target: `network-${networkId}`,
- type: 'smoothstep',
- animated: false,
+ type: 'bezier',
+ animated: true,
deletable: false,
selectable: false,
focusable: false,
From 67ee612c49bdecf864264855dda4951f72410647 Mon Sep 17 00:00:00 2001
From: Simon Loir
Date: Fri, 5 Dec 2025 19:04:38 +0100
Subject: [PATCH 15/15] chore: bump package version
---
apps/web/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index 4881568..dedb826 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "web",
- "version": "0.11.1",
+ "version": "0.11.2",
"type": "module",
"private": true,
"scripts": {