import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; import { Background, BackgroundVariant, MarkerType, MiniMap, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, useViewport, type Edge, type Node, type NodeTypes, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ServiceEntity } from '@/lib/api-client'; import { createDeployment, deleteService, restartService, startService, stopService, } from '@/lib/api-client'; import { useToast } from '@/shared/hooks/use-toast'; import { inferAutoConnections, type ServiceVariable } from '../auto-connections'; import { DEFAULT_VIEWPORT, type ProjectCanvasMetadata } from '../model'; import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout'; import { buildMetadataFromFlow, GROUP_DEFAULT_HEIGHT, GROUP_DEFAULT_WIDTH, GROUP_PAD_BOTTOM, GROUP_PAD_TOP, GROUP_PAD_X, GRID_STEP, groupSlot, resolveFreeSpot, type CanvasNode, type Rect, } from './layout'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { ServiceInspector, type InspectorGroup } from './inspector'; import { serviceAccent } from './service-visuals'; import { Plus, Layers, Maximize2, Minus, Play, Square, Rocket, Trash2, ArrowUpRight, Globe, Wand2, Pencil, Ungroup, } from 'lucide-react'; type CanvasProps = { projectId: string; services: ServiceEntity[]; variablesByService: Record; onAddService: () => void; onOpenService: (serviceId: string) => void; onOpenSection?: (serviceId: string, section: string) => void; readOnly?: boolean; }; type ServiceCanvasNode = Node; type GroupCanvasNode = Node; type CanvasEdge = Edge<{ reasons: string[] }>; const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']); const nodeTypes: NodeTypes = { serviceNode: ServiceNode, groupNode: GroupNode, }; type ContextMenuState = { kind: 'service' | 'group' | 'pane'; targetId?: string; x: number; y: number; flowX?: number; flowY?: number; } | null; function toFlowNodes( metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService'], onRenameGroup: (groupId: string, title: string) => void, ): CanvasNode[] { const groups = metadata.groups.map( (group): GroupCanvasNode => ({ id: group.id, type: 'groupNode', data: { title: group.title, onRename: onRenameGroup }, position: group.position, draggable: true, selectable: true, style: { width: group.width, height: group.height, }, }), ); const layoutMap = new Map(metadata.nodes.map((layout) => [layout.serviceId, layout])); const groupIds = new Set(groups.map((group) => group.id)); const serviceNodes = services.map((service): ServiceCanvasNode => { const layout = layoutMap.get(service.id); const parentId = layout?.groupId && groupIds.has(layout.groupId) ? layout.groupId : undefined; return { id: service.id, type: 'serviceNode', position: layout?.position ?? { x: 0, y: 0 }, parentId, extent: parentId ? 'parent' : undefined, data: { service, selected: false, onOpen: onOpenService, }, draggable: true, selectable: true, style: { width: NODE_WIDTH, }, }; }); return [...groups, ...serviceNodes]; } function toFlowEdges( links: ReturnType, positionOf: (serviceId: string) => { x: number; y: number } | undefined, statusOf: (serviceId: string) => string | undefined, ): CanvasEdge[] { return links.map((link) => { const source = positionOf(link.edge.sourceServiceId); const target = positionOf(link.edge.targetServiceId); // Choose handle sides by relative position so edges flow forward // instead of looping back through the canvas. const forward = !source || !target || source.x <= target.x; const live = TRANSIENT_STATUSES.has(statusOf(link.edge.sourceServiceId) ?? '') || TRANSIENT_STATUSES.has(statusOf(link.edge.targetServiceId) ?? ''); return { id: link.edge.id, source: link.edge.sourceServiceId, target: link.edge.targetServiceId, sourceHandle: forward ? 's-r' : 's-l', targetHandle: forward ? 't-l' : 't-r', animated: live, data: { reasons: link.reasons, }, markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(180, 227, 74, 0.55)', width: 13, height: 13, }, style: { stroke: 'rgba(180, 227, 74, 0.42)', strokeWidth: 1.6, }, className: 'edge-premium', }; }); } function ZoomControls({ onTidy }: { onTidy: () => void }) { const { zoom } = useViewport(); const { zoomIn, zoomOut, fitView, setViewport } = useReactFlow(); return (
); } function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, onOpenSection, readOnly }: CanvasProps) { const wrapperRef = useRef(null); const persistTimeout = useRef(null); const hydratedRef = useRef(false); const viewportRestoredRef = useRef(false); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [selectedServiceId, setSelectedServiceId] = useState(null); const [viewportTick, setViewportTick] = useState(0); const [envFilter, setEnvFilter] = useState('all'); const [contextMenu, setContextMenu] = useState(null); const queryClient = useQueryClient(); const toast = useToast(); const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow(); const environments = useMemo(() => { const envs = new Set(services.map((service) => service.environment ?? 'production')); return Array.from(envs).sort(); }, [services]); const visibleServices = useMemo( () => (envFilter === 'all' ? services : services.filter((service) => (service.environment ?? 'production') === envFilter)), [services, envFilter], ); const visibleIds = useMemo(() => new Set(visibleServices.map((service) => service.id)), [visibleServices]); const serviceFingerprint = useMemo( () => services.map((service) => service.id).sort().join('|'), [services], ); const inferredLinks = useMemo( () => inferAutoConnections(services, variablesByService), [services, variablesByService], ); const edges = useMemo(() => { const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position; const statusOf = (serviceId: string) => services.find((service) => service.id === serviceId)?.status; return toFlowEdges(inferredLinks, positionOf, statusOf).filter( (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target), ); }, [inferredLinks, visibleIds, nodes, services]); const renameGroup = useCallback( (groupId: string, title: string) => { setNodes((current) => current.map((node) => node.id === groupId && node.type === 'groupNode' ? { ...node, data: { ...node.data, title } } : node, ), ); }, [setNodes], ); const invalidateServices = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); void queryClient.invalidateQueries({ queryKey: ['service-runtime'] }); }, [projectId, queryClient]); const actionError = (error: unknown) => toast.showToast(error instanceof Error ? error.message : 'Action failed', 'error'); const deployMutation = useMutation({ mutationFn: (serviceId: string) => createDeployment(serviceId, { trigger: 'manual' }), onSuccess: () => { toast.showToast('Deployment started', 'success'); invalidateServices(); }, onError: actionError, }); const startMutation = useMutation({ mutationFn: (serviceId: string) => startService(serviceId), onSuccess: () => { toast.showToast('Service started', 'success'); invalidateServices(); }, onError: actionError, }); const restartMutation = useMutation({ mutationFn: (serviceId: string) => restartService(serviceId), onSuccess: () => { toast.showToast('Service restarted', 'success'); invalidateServices(); }, onError: actionError, }); const stopMutation = useMutation({ mutationFn: (serviceId: string) => stopService(serviceId), onSuccess: () => { toast.showToast('Service stopped', 'success'); invalidateServices(); }, onError: actionError, }); const deleteMutation = useMutation({ mutationFn: (serviceId: string) => deleteService(serviceId), onSuccess: () => { toast.showToast('Service deleted', 'success'); invalidateServices(); setSelectedServiceId(null); }, onError: actionError, }); const actionPending = deployMutation.isPending || startMutation.isPending || restartMutation.isPending || stopMutation.isPending || deleteMutation.isPending; const tidyLayout = useCallback(() => { const positions = computeLayeredLayout( services, inferredLinks.map((link) => link.edge), ); setNodes((current) => current.map((node) => { if (node.type !== 'serviceNode' || node.parentId) { return node; } const next = positions.get(node.id); return next ? { ...node, position: next } : node; }), ); window.setTimeout(() => void fitView({ padding: 0.18, duration: 300 }), 40); }, [services, inferredLinks, setNodes, fitView]); useEffect(() => { const hasStoredViewport = hasCanvasMetadata(projectId); const layout = hasStoredViewport ? undefined : layoutFromVariables(services, variablesByService); const metadata = loadCanvasMetadata(projectId, services, layout); const nextNodes = toFlowNodes(metadata, services, onOpenService, renameGroup); viewportRestoredRef.current = false; setNodes(nextNodes); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { const isDefaultViewport = metadata.viewport.x === DEFAULT_VIEWPORT.x && metadata.viewport.y === DEFAULT_VIEWPORT.y && metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom; if (!hasStoredViewport || isDefaultViewport) { void fitView({ padding: 0.18, duration: 120 }); } else { void setViewport(metadata.viewport, { duration: 120 }); } viewportRestoredRef.current = true; }); }); hydratedRef.current = true; }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services, renameGroup, variablesByService]); useEffect(() => { if (!hydratedRef.current || !viewportRestoredRef.current) { return; } if (persistTimeout.current) { window.clearTimeout(persistTimeout.current); } persistTimeout.current = window.setTimeout(() => { const metadata = buildMetadataFromFlow(nodes, getViewport()); saveCanvasMetadata(projectId, metadata); }, 150); return () => { if (persistTimeout.current) { window.clearTimeout(persistTimeout.current); } }; }, [getViewport, nodes, projectId, viewportTick]); useEffect(() => { setNodes((prev) => prev.map((node) => { if (node.type !== 'serviceNode') { return node; } return { ...node, data: { ...node.data, selected: node.id === selectedServiceId, }, }; }), ); }, [selectedServiceId, setNodes]); // Canvas keyboard shortcuts: F fit, 0 reset zoom, +/- zoom, Esc deselect. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if ( target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable) ) { return; } if (event.metaKey || event.ctrlKey || event.altKey) { return; } switch (event.key) { case 'f': case 'F': void fitView({ padding: 0.18, duration: 240 }); break; case '0': void setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 }); break; case '=': case '+': void zoomIn({ duration: 160 }); break; case '-': case '_': void zoomOut({ duration: 160 }); break; case 'Escape': setSelectedServiceId(null); setContextMenu(null); break; } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [fitView, setViewport, zoomIn, zoomOut]); const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => { for (const node of nodes) { if (node.type !== 'groupNode' || node.id === ignoreGroupId) { continue; } const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH; const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT; const base = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; if (x >= base.x && x <= base.x + width && y >= base.y && y <= base.y + height) { return node; } } return null; }, [getInternalNode, nodes]); const dragStartPositions = useRef>(new Map()); const onNodeDragStart = useCallback( (_event: MouseEvent, node: CanvasNode) => { const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; dragStartPositions.current.set(node.id, { ...abs }); }, [getInternalNode], ); const absoluteServiceRects = useCallback( (excludeId: string): Rect[] => nodes .filter((node) => node.type === 'serviceNode' && node.id !== excludeId) .map((node) => { const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; }), [getInternalNode, nodes], ); const onNodeDragStop = useCallback( (_event: MouseEvent, movedNode: CanvasNode) => { if (movedNode.type !== 'serviceNode') { return; } const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position; const fallback = dragStartPositions.current.get(movedNode.id) ?? basePosition; const centerX = basePosition.x + NODE_WIDTH / 2; const centerY = basePosition.y + NODE_HEIGHT / 2; const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId); if (targetGroup) { const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position; setNodes((current) => { const slot = groupSlot( current, targetGroup.id, targetGroup, basePosition.x - groupBase.x, basePosition.y - groupBase.y, movedNode.id, ); return current.map((node) => { if (node.id === targetGroup.id && node.type === 'groupNode') { return { ...node, style: { ...node.style, width: slot.width, height: slot.height } }; } if (node.id === movedNode.id && node.type === 'serviceNode') { return { ...node, parentId: targetGroup.id, extent: 'parent' as const, position: { x: slot.x, y: slot.y }, }; } return node; }); }); return; } // Member staying inside its own frame — keep membership, but resolve // against siblings so nodes can't stack on each other. if (movedNode.parentId) { const parent = nodes.find((entry) => entry.id === movedNode.parentId && entry.type === 'groupNode'); if (parent) { const parentAbs = getInternalNode(parent.id)?.internals.positionAbsolute ?? parent.position; const parentW = typeof parent.style?.width === 'number' ? parent.style.width : GROUP_DEFAULT_WIDTH; const parentH = typeof parent.style?.height === 'number' ? parent.style.height : GROUP_DEFAULT_HEIGHT; const inside = centerX >= parentAbs.x && centerX <= parentAbs.x + parentW && centerY >= parentAbs.y && centerY <= parentAbs.y + parentH; if (inside) { const siblingRects: Rect[] = nodes .filter( (entry) => entry.type === 'serviceNode' && entry.parentId === parent.id && entry.id !== movedNode.id, ) .map((entry) => ({ x: entry.position.x, y: entry.position.y, w: NODE_WIDTH, h: NODE_HEIGHT })); const relDesired = { x: basePosition.x - parentAbs.x, y: basePosition.y - parentAbs.y }; const relFallback = { x: Math.max(GROUP_PAD_X, Math.min(parentW - GROUP_PAD_X - NODE_WIDTH, fallback.x - parentAbs.x)), y: Math.max(GROUP_PAD_TOP, Math.min(parentH - GROUP_PAD_BOTTOM - NODE_HEIGHT, fallback.y - parentAbs.y)), }; const bounds: Rect = { x: GROUP_PAD_X, y: GROUP_PAD_TOP, w: Math.max(NODE_WIDTH, parentW - GROUP_PAD_X * 2), h: Math.max(NODE_HEIGHT, parentH - GROUP_PAD_TOP - GROUP_PAD_BOTTOM), }; const spot = resolveFreeSpot(relDesired, siblingRects, relFallback, bounds); setNodes((current) => current.map((entry) => entry.id === movedNode.id && entry.type === 'serviceNode' ? { ...entry, position: spot } : entry, ), ); return; } } } // Free placement — snapped to the canvas grid and pushed off any node it // would overlap; reverts to the pre-drag spot if fully boxed in. const spot = resolveFreeSpot(basePosition, absoluteServiceRects(movedNode.id), fallback); setNodes((current) => current.map((node) => { if (node.id !== movedNode.id || node.type !== 'serviceNode') { return node; } return { ...node, parentId: undefined, extent: undefined, position: spot, }; }), ); }, [absoluteServiceRects, getGroupUnderPoint, getInternalNode, nodes, setNodes], ); const addGroup = useCallback( (at?: { x: number; y: number }) => { const bounds = wrapperRef.current?.getBoundingClientRect(); const center = at ?? (bounds ? screenToFlowPosition({ x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2, }) : { x: 180, y: 140 }); const id = `group-${Date.now()}`; setNodes((current) => [ { id, type: 'groupNode', position: { x: center.x - GROUP_DEFAULT_WIDTH / 2, y: center.y - GROUP_DEFAULT_HEIGHT / 2, }, data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup }, draggable: true, selectable: true, style: { width: GROUP_DEFAULT_WIDTH, height: GROUP_DEFAULT_HEIGHT, }, } satisfies GroupCanvasNode, // Groups must precede their children in the array — keep it first. ...current, ]); return id; }, [screenToFlowPosition, setNodes, renameGroup], ); // Move a service node in/out of a group — mirrors the drag-drop math so // assignment via the inspector lands identically to dropping the node on a // group frame. const assignToGroup = useCallback( (serviceId: string, groupId: string | null) => { const node = nodes.find((entry) => entry.id === serviceId && entry.type === 'serviceNode'); if (!node || node.parentId === (groupId ?? undefined)) { return; } const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position; if (groupId === null) { setNodes((current) => { const occupied: Rect[] = current .filter((entry) => entry.type === 'serviceNode' && entry.id !== serviceId) .map((entry) => { const entryAbs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; return { x: entryAbs.x, y: entryAbs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; }); const spot = resolveFreeSpot(abs, occupied, abs); return current.map((entry) => entry.id === serviceId && entry.type === 'serviceNode' ? { ...entry, parentId: undefined, extent: undefined, position: spot } : entry, ); }); return; } setNodes((current) => { const group = current.find((entry) => entry.id === groupId && entry.type === 'groupNode'); if (!group) { return current; } const groupAbs = getInternalNode(groupId)?.internals.positionAbsolute ?? group.position; const slot = groupSlot(current, groupId, group, abs.x - groupAbs.x, abs.y - groupAbs.y); return current.map((entry) => { if (entry.id === groupId && entry.type === 'groupNode') { return { ...entry, style: { ...entry.style, width: slot.width, height: slot.height } }; } if (entry.id === serviceId && entry.type === 'serviceNode') { return { ...entry, parentId: groupId, extent: 'parent' as const, position: { x: slot.x, y: slot.y } }; } return entry; }); }); }, [getInternalNode, nodes, setNodes], ); // Create a group frame wrapped around the service and adopt the node into it. const newGroupForService = useCallback( (serviceId: string) => { const node = nodes.find((entry) => entry.id === serviceId && entry.type === 'serviceNode'); if (!node) { return; } const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position; const groupId = `group-${Date.now()}`; setNodes((current) => [ // Parent precedes the child in the array. { id: groupId, type: 'groupNode', position: { x: abs.x - GROUP_PAD_X, y: abs.y - GROUP_PAD_TOP }, data: { title: `Group ${current.filter((entry) => entry.type === 'groupNode').length + 1}`, onRename: renameGroup, }, draggable: true, selectable: true, style: { width: NODE_WIDTH + GROUP_PAD_X * 2, height: NODE_HEIGHT + GROUP_PAD_TOP + GROUP_PAD_BOTTOM, }, } satisfies GroupCanvasNode, ...current.map((entry) => entry.id === serviceId && entry.type === 'serviceNode' ? { ...entry, parentId: groupId, extent: 'parent' as const, position: { x: GROUP_PAD_X, y: GROUP_PAD_TOP }, } : entry, ), ]); }, [getInternalNode, nodes, renameGroup, setNodes], ); const deleteGroup = useCallback( (groupId: string) => { setNodes((current) => { const memberIds = new Set( current .filter((entry) => entry.type === 'serviceNode' && entry.parentId === groupId) .map((entry) => entry.id), ); // Freed members must not land on free nodes — resolve each in turn and // treat already-placed members as occupied for the next. const occupied: Rect[] = current .filter((entry) => entry.type === 'serviceNode' && !memberIds.has(entry.id)) .map((entry) => { const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; }); const unparented = current.map((entry) => { if (entry.type !== 'serviceNode' || entry.parentId !== groupId) { return entry; } const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; const spot = resolveFreeSpot(abs, occupied, abs); occupied.push({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT }); return { ...entry, parentId: undefined, extent: undefined, position: spot }; }); return unparented.filter((entry) => entry.id !== groupId); }); setContextMenu(null); }, [getInternalNode, setNodes], ); const requestGroupRename = useCallback( (groupId: string) => { setNodes((current) => current.map((entry) => entry.id === groupId && entry.type === 'groupNode' ? { ...entry, data: { ...entry.data, renameNonce: (entry.data.renameNonce ?? 0) + 1 } } : entry, ), ); }, [setNodes], ); // Env filter hides nodes via React Flow's `hidden` flag — positions and // persisted layout stay intact for services outside the current filter. const renderedNodes = useMemo( () => nodes.map((node) => node.type === 'serviceNode' && !visibleIds.has(node.id) ? { ...node, hidden: true } : node, ), [nodes, visibleIds], ); const menuPosition = useCallback((clientX: number, clientY: number, menuW = 190, menuH = 240) => { const bounds = wrapperRef.current?.getBoundingClientRect(); const x = Math.min(clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW)); const y = Math.min(clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH)); return { x, y }; }, []); const onNodeContextMenu = useCallback( (event: MouseEvent, node: CanvasNode) => { event.preventDefault(); if (node.type === 'serviceNode') { setSelectedServiceId(node.id); setContextMenu({ kind: 'service', targetId: node.id, ...menuPosition(event.clientX, event.clientY) }); } else if (node.type === 'groupNode') { setContextMenu({ kind: 'group', targetId: node.id, ...menuPosition(event.clientX, event.clientY, 190, 140) }); } }, [menuPosition], ); const onPaneContextMenu = useCallback( (event: MouseEvent | globalThis.MouseEvent) => { event.preventDefault(); const flow = screenToFlowPosition({ x: event.clientX, y: event.clientY }); setContextMenu({ kind: 'pane', ...menuPosition(event.clientX, event.clientY, 200, 170), flowX: flow.x, flowY: flow.y }); }, [menuPosition, screenToFlowPosition], ); const contextService = contextMenu?.kind === 'service' && contextMenu.targetId ? services.find((service) => service.id === contextMenu.targetId) ?? null : null; const contextGroup = contextMenu?.kind === 'group' && contextMenu.targetId ? nodes.find( (node): node is GroupCanvasNode => node.id === contextMenu.targetId && node.type === 'groupNode', ) ?? null : null; const inspectorGroups: InspectorGroup[] = useMemo( () => nodes .filter((node): node is GroupCanvasNode => node.type === 'groupNode') .map((node) => ({ id: node.id, title: node.data.title })), [nodes], ); const selectedNode = selectedServiceId ? nodes.find((node) => node.id === selectedServiceId) : undefined; const selectedService = services.find((service) => service.id === selectedServiceId) ?? null; const selectedConnections = useMemo(() => { if (!selectedService) { return []; } const nameOf = (id: string) => services.find((service) => service.id === id)?.name ?? id; return inferredLinks .filter( (link) => link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id, ) .map((link) => ({ id: link.edge.id, outbound: link.edge.sourceServiceId === selectedService.id, peer: link.edge.sourceServiceId === selectedService.id ? nameOf(link.edge.targetServiceId) : nameOf(link.edge.sourceServiceId), peerId: link.edge.sourceServiceId === selectedService.id ? link.edge.targetServiceId : link.edge.sourceServiceId, reasons: link.reasons, })); }, [inferredLinks, selectedService, services]); const minimapNodeColor = useCallback( (node: CanvasNode) => { if (node.type === 'groupNode') { return 'rgba(255,255,255,0.08)'; } return serviceAccent((node.data as ServiceNodeData).service); }, [], ); return (
{ setSelectedServiceId(null); setContextMenu(null); }} onMoveStart={() => setContextMenu(null)} onNodeClick={(_event, node) => { setContextMenu(null); if (node.type === 'serviceNode') { setSelectedServiceId(node.id); } }} onNodeDoubleClick={(_event, node) => { if (node.type === 'serviceNode') { onOpenService(node.id); } }} onNodeContextMenu={onNodeContextMenu} onPaneContextMenu={onPaneContextMenu} onNodeDragStart={onNodeDragStart} onNodeDragStop={onNodeDragStop} snapToGrid snapGrid={[GRID_STEP, GRID_STEP]} onMoveEnd={() => setViewportTick((value) => value + 1)} panOnDrag zoomOnScroll zoomOnDoubleClick={false} nodesConnectable={false} nodeDragThreshold={3} onlyRenderVisibleElements minZoom={0.2} maxZoom={2.2} deleteKeyCode={null} selectionKeyCode="Shift" proOptions={{ hideAttribution: true }} > {/* Floating: env filter + group (top-left) */}
{environments.length > 1 && (
{['all', ...environments].map((env) => ( ))}
)} {!readOnly && ( )}
{/* Floating: zoom controls (bottom-left) */}
{/* Floating: hint (bottom-center) — fades once a node is selected */} {!selectedService && services.length > 0 && (

Click to inspect · Double-click to open · Right-click for actions · F to fit

)} {/* Inspector (right) */} {selectedService && ( deployMutation.mutate(selectedService.id), start: () => startMutation.mutate(selectedService.id), restart: () => restartMutation.mutate(selectedService.id), stop: () => stopMutation.mutate(selectedService.id), remove: () => { if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) { deleteMutation.mutate(selectedService.id); } }, pending: actionPending, deployPending: deployMutation.isPending, restartPending: restartMutation.isPending, }} onClose={() => setSelectedServiceId(null)} onOpenService={onOpenService} onOpenSection={(serviceId, section) => onOpenSection ? onOpenSection(serviceId, section) : onOpenService(serviceId) } onSelectPeer={setSelectedServiceId} onAssignGroup={assignToGroup} onNewGroupFor={newGroupForService} /> )} {/* Empty state */} {services.length === 0 && (

Deploy your first service

Add a service from a git repo or Docker image - it runs on a private network with the rest of this project.

{!readOnly && ( )}
)} {/* Context menus — service node, group frame, or empty pane */} {contextMenu && (
{contextMenu.kind === 'pane' && ( <> {!readOnly && ( <> )}
)} {contextMenu.kind === 'group' && contextGroup && ( <>

{contextGroup.data.title}

Group

)} {contextMenu.kind === 'service' && contextService && ( <>

{contextService.name}

{contextService.status}

{(contextService.domain || contextService.publicUrl) && ( Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} )} {!readOnly && ( <> {contextService.status !== 'running' && ( )}
)} )}
)}
); } export function ProjectCanvas(props: CanvasProps) { return ( ); }