Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,18 @@
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc';
import PaddedSpinner from '@/components/padded-spinner';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { BreadCrumbs } from '@/components/app-page-context';
import PageTitle from '@/components/page-title';
import { Badge } from '@/components/ui/badge';
import { CreateService } from '@/app/dashboard/environments/[deploymentEnvId]/components/create-service';
import { useRouter } from 'next/navigation';
import ServiceSettingsDropdown from '@/app/dashboard/environments/[deploymentEnvId]/components/service-settings-dropdown';
import CardsGrid from '@/components/cards-grid';
import ServiceFlowDiagram from '@/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram';

export default function EnvServicesPage({
deploymentEnvId,
}: {
readonly deploymentEnvId: string;
}) {
const trpc = useTRPC();
const router = useRouter();
const { data: services } = useQuery(
trpc.services.listServices.queryOptions({
environmentId: deploymentEnvId,
Expand All @@ -37,7 +26,7 @@ export default function EnvServicesPage({
);
if (!services || !env) return <PaddedSpinner />;
return (
<>
<div className='space-y-4'>
<BreadCrumbs
breadcrumbs={[
{
Expand All @@ -60,32 +49,7 @@ export default function EnvServicesPage({
<CreateService environmentId={deploymentEnvId} />
</div>

<CardsGrid>
{services.map((service) => (
<Card
key={service.id}
onClick={() =>
router.push(`/dashboard/services/${service.id}`)
}
className='cursor-pointer hover:bg-muted'
>
<CardHeader>
<CardTitle>{service.name}</CardTitle>
{service.description && (
<CardDescription>
{service.description}
</CardDescription>
)}
<CardAction onClick={(e) => e.stopPropagation()}>
<ServiceSettingsDropdown service={service} />
</CardAction>
</CardHeader>
<CardContent className='flex justify-end'>
<Badge>{service.server.name}</Badge>
</CardContent>
</Card>
))}
</CardsGrid>
</>
<ServiceFlowDiagram services={services} />
</div>
);
}
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) {
Comment on lines +35 to +37
Copy link

Copilot AI Dec 5, 2025

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.

Copilot uses AI. Check for mistakes.
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 isDark condition, making the conditional check pointless.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
style: {
stroke: isDark
? 'oklch(0.556 0 0)'
: 'oklch(0.556 0 0)',
strokeWidth: 2,
style: {
stroke: 'oklch(0.556 0 0)',
strokeWidth: 2,
strokeDasharray: '5,5',
},
🤖 Prompt for AI Agents
In
apps/web/app/dashboard/environments/[deploymentEnvId]/components/service-flow-diagram.tsx
around lines 152 to 156, the stroke color uses a ternary that returns the same
OKLCH value for both branches; remove the redundant conditional or replace it
with distinct colors for dark and light modes. Update the style.stroke to either
a single color literal (remove ternary) or supply two different OKLCH/RGB values
(one for isDark true and one for false) that provide sufficient contrast for
each theme.

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>
);
}
Loading
Loading