-
Notifications
You must be signed in to change notification settings - Fork 0
feat: replace service cards with React Flow bipartite graph showing network topology #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
38f49ed
f4eb29e
68be1a7
fe18fca
e0fd94f
c7cf58c
268fc74
e7e13e6
5de7019
e216e8f
72f4f85
4dc7f68
a7e8d8f
951872e
67ee612
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import dagre from 'dagre'; | ||
| 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 | ||
| */ | ||
| 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: 55, // 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, | ||
| }); | ||
| }); | ||
|
|
||
| edges.forEach((edge) => { | ||
| dagreGraph.setEdge(edge.source, edge.target); | ||
| }); | ||
|
|
||
| dagre.layout(dagreGraph); | ||
|
|
||
| const layoutedNodes = nodes.map((node) => { | ||
| const nodeWithPosition = dagreGraph.node(node.id); | ||
| return { | ||
| ...node, | ||
| position: { | ||
| x: nodeWithPosition.x - 150, | ||
| y: nodeWithPosition.y - 75, | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| return { nodes: layoutedNodes as Node[], edges }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { Handle, Position } from '@xyflow/react'; | ||
| import { Card, CardHeader, CardTitle } from '@/components/ui/card'; | ||
| import { NetworkIcon } from 'lucide-react'; | ||
|
|
||
| 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 ( | ||
| <> | ||
| <Handle | ||
| type='target' | ||
| position={Position.Left} | ||
| isConnectable={isConnectable} | ||
| /> | ||
|
|
||
| <Card className='w-[300px]'> | ||
| <CardHeader> | ||
| <CardTitle className='leading-relaxed'> | ||
| <NetworkIcon className='inline size-3' /> {network.name} | ||
| </CardTitle> | ||
| </CardHeader> | ||
| </Card> | ||
| </> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,180 @@ | ||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||
| import { useCallback, useMemo, useEffect } from 'react'; | ||||||||||||||||||||||
| import { | ||||||||||||||||||||||
| ReactFlow, | ||||||||||||||||||||||
| Background, | ||||||||||||||||||||||
| Controls, | ||||||||||||||||||||||
| Node, | ||||||||||||||||||||||
| Edge, | ||||||||||||||||||||||
| useNodesState, | ||||||||||||||||||||||
| useEdgesState, | ||||||||||||||||||||||
| ConnectionMode, | ||||||||||||||||||||||
| } from '@xyflow/react'; | ||||||||||||||||||||||
| import '@xyflow/react/dist/style.css'; | ||||||||||||||||||||||
| import { useRouter } from 'next/navigation'; | ||||||||||||||||||||||
| import { useTheme } from 'next-themes'; | ||||||||||||||||||||||
| import { inferProcedureOutput } from '@trpc/server'; | ||||||||||||||||||||||
| import { appRouter } from '@repo/api'; | ||||||||||||||||||||||
| import { getLayoutedElements } from './flow-utils'; | ||||||||||||||||||||||
| import { ServiceNode } from './service-node'; | ||||||||||||||||||||||
| import { NetworkNode } from './network-node'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type Service = inferProcedureOutput< | ||||||||||||||||||||||
| typeof appRouter.services.listServices | ||||||||||||||||||||||
| >[number]; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type ServiceFlowDiagramProps = { | ||||||||||||||||||||||
| readonly services: Service[]; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * 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) { | ||||||||||||||||||||||
| const router = useRouter(); | ||||||||||||||||||||||
| const { resolvedTheme } = useTheme(); | ||||||||||||||||||||||
| const isDark = resolvedTheme === 'dark'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create nodes and edges from services | ||||||||||||||||||||||
| const { nodes: initialNodes, edges: initialEdges } = useMemo(() => { | ||||||||||||||||||||||
| // Collect all unique networks | ||||||||||||||||||||||
| const networksMap = new Map<string, { id: string; name: string }>(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| services.forEach((service) => { | ||||||||||||||||||||||
| service.networks.forEach((network) => { | ||||||||||||||||||||||
| const networkId = String(network.id); | ||||||||||||||||||||||
| if (!networksMap.has(networkId)) { | ||||||||||||||||||||||
| networksMap.set(networkId, { | ||||||||||||||||||||||
| id: networkId, | ||||||||||||||||||||||
| name: String(network.name), | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create service nodes | ||||||||||||||||||||||
| const serviceNodes: Node[] = services.map((service) => { | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| id: service.id, | ||||||||||||||||||||||
| type: 'service', | ||||||||||||||||||||||
| data: { | ||||||||||||||||||||||
| service, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| position: { x: 0, y: 0 }, // Will be set by dagre layout | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create network nodes | ||||||||||||||||||||||
| const networkNodes: Node[] = Array.from(networksMap.values()).map( | ||||||||||||||||||||||
| (network) => { | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| id: `network-${network.id}`, | ||||||||||||||||||||||
| type: 'network', | ||||||||||||||||||||||
| position: { x: 0, y: 0 }, // Will be set by dagre layout | ||||||||||||||||||||||
| data: { | ||||||||||||||||||||||
| network, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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: 'bezier', | ||||||||||||||||||||||
| animated: true, | ||||||||||||||||||||||
| deletable: false, | ||||||||||||||||||||||
| selectable: false, | ||||||||||||||||||||||
| focusable: false, | ||||||||||||||||||||||
| style: { | ||||||||||||||||||||||
| stroke: 'oklch(0.556 0 0)', | ||||||||||||||||||||||
| strokeWidth: 2, | ||||||||||||||||||||||
|
Comment on lines
+104
to
+106
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Edge stroke color is identical for dark and light modes. The ternary operator on lines 153-155 returns the same OKLCH color value regardless of the Apply this diff to either use distinct colors or remove the redundant ternary: style: {
- stroke: isDark
- ? 'oklch(0.556 0 0)'
- : 'oklch(0.556 0 0)',
+ stroke: 'oklch(0.556 0 0)',
strokeWidth: 2,
strokeDasharray: '5,5',
},Or, if different colors are desired for better contrast: style: {
stroke: isDark
- ? 'oklch(0.556 0 0)'
- : 'oklch(0.556 0 0)',
+ ? 'oklch(0.708 0 0)' // lighter for dark mode
+ : 'oklch(0.556 0 0)', // darker for light mode
strokeWidth: 2,
strokeDasharray: '5,5',
},📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| strokeDasharray: '5,5', | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Apply automatic layout based on edges | ||||||||||||||||||||||
| return getLayoutedElements(nodes, edges); | ||||||||||||||||||||||
| }, [services]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); | ||||||||||||||||||||||
| const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Update nodes and edges when they change | ||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| setNodes(initialNodes); | ||||||||||||||||||||||
| setEdges(initialEdges); | ||||||||||||||||||||||
| }, [initialNodes, initialEdges, setNodes, setEdges]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const onNodeClick = useCallback( | ||||||||||||||||||||||
| (_event: React.MouseEvent, node: Node) => { | ||||||||||||||||||||||
| // Only navigate for service nodes, not network nodes | ||||||||||||||||||||||
| if (!node.id.startsWith('network-')) { | ||||||||||||||||||||||
| router.push(`/dashboard/services/${node.id}`); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| [router] | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (services.length === 0) { | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <div className='flex items-center justify-center h-[600px] border border-border rounded-lg bg-card text-muted-foreground'> | ||||||||||||||||||||||
| No services found in this environment | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <div className='border border-border rounded-lg w-full h-[70vh]'> | ||||||||||||||||||||||
| <ReactFlow | ||||||||||||||||||||||
| nodes={nodes} | ||||||||||||||||||||||
| edges={edges} | ||||||||||||||||||||||
| onNodesChange={onNodesChange} | ||||||||||||||||||||||
| onEdgesChange={onEdgesChange} | ||||||||||||||||||||||
| onNodeClick={onNodeClick} | ||||||||||||||||||||||
| fitView | ||||||||||||||||||||||
| attributionPosition='bottom-right' | ||||||||||||||||||||||
| edgesFocusable={false} | ||||||||||||||||||||||
| nodesDraggable={true} | ||||||||||||||||||||||
| nodesConnectable={false} | ||||||||||||||||||||||
| elementsSelectable={true} | ||||||||||||||||||||||
| connectionMode={ConnectionMode.Loose} | ||||||||||||||||||||||
| nodeTypes={{ | ||||||||||||||||||||||
| service: ServiceNode, | ||||||||||||||||||||||
| network: NetworkNode, | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <Background | ||||||||||||||||||||||
| color={isDark ? 'oklch(1 0 0 / 10%)' : 'oklch(0.922 0 0)'} | ||||||||||||||||||||||
| gap={16} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| <Controls | ||||||||||||||||||||||
| 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)'}`, | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
| showInteractive={false} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| </ReactFlow> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The component lacks JSDoc documentation explaining its purpose, props, and behavior. Given the complexity of the bipartite graph implementation with automatic layout, adding documentation would help future maintainers understand the component's design decisions and the relationship between services and networks.