Files
Containr/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx
T
Tomáš DvořákandDevin a6b8397dd8 test: cron schedule + webhook URL + canvas layout coverage (#73)
- calculateNextRun: field-count, garbage, bad-zone errors; CRON_TZ
  actually shifts the instant (UTC vs Asia/Tokyo)
- validWebhookURL: http/https only, host required, whitespace trimmed
- canvas layout math extracted to canvas/layout.ts (component files may
  only export components) and covered: rectsOverlap gap zone,
  resolveFreeSpot grid-snap/collision/bounds-fallback, groupSlot
  fit/append/grow/ignore-dragged, buildMetadataFromFlow serialization

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:34:51 +02:00

1254 lines
45 KiB
TypeScript

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<string, ServiceVariable[]>;
onAddService: () => void;
onOpenService: (serviceId: string) => void;
onOpenSection?: (serviceId: string, section: string) => void;
readOnly?: boolean;
};
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
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<typeof inferAutoConnections>,
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 (
<div className="canvas-pill flex items-center">
<button
type="button"
onClick={() => zoomOut({ duration: 160 })}
className="canvas-pill-btn"
title="Zoom out (-)"
>
<Minus size={14} />
</button>
<button
type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="canvas-pill-btn mono w-12 text-[11px]"
title="Reset zoom (0)"
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
onClick={() => zoomIn({ duration: 160 })}
className="canvas-pill-btn"
title="Zoom in (+)"
>
<Plus size={14} />
</button>
<div className="w-px h-4 bg-[var(--border-subtle)] mx-1" />
<button
type="button"
onClick={() => fitView({ padding: 0.18, duration: 240 })}
className="canvas-pill-btn"
title="Fit view (F)"
>
<Maximize2 size={13} />
</button>
<button
type="button"
onClick={onTidy}
className="canvas-pill-btn"
title="Tidy layout"
>
<Wand2 size={13} />
</button>
</div>
);
}
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, onOpenSection, readOnly }: CanvasProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const viewportRestoredRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
const [envFilter, setEnvFilter] = useState<string>('all');
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const queryClient = useQueryClient();
const toast = useToast();
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow<CanvasNode, CanvasEdge>();
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<Map<string, { x: number; y: number }>>(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 (
<div ref={wrapperRef} className="relative h-full min-h-0 w-full bg-[var(--bg-void)]">
<ReactFlow
nodes={renderedNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onPaneClick={() => {
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 }}
>
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" />
<MiniMap
position="bottom-right"
pannable
zoomable
nodeColor={minimapNodeColor}
bgColor="rgba(14, 15, 18, 0.92)"
maskColor="rgba(12, 13, 15, 0.72)"
className="canvas-minimap hidden md:block"
/>
</ReactFlow>
{/* Floating: env filter + group (top-left) */}
<div className="absolute left-4 top-4 flex items-center gap-2">
{environments.length > 1 && (
<div className="canvas-pill flex items-center gap-0.5 p-1">
{['all', ...environments].map((env) => (
<button
key={env}
type="button"
onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${
envFilter === env
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
{env}
</button>
))}
</div>
)}
{!readOnly && (
<button
type="button"
onClick={() => addGroup()}
className="canvas-pill canvas-pill-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Add group"
>
<Layers size={13} />
Group
</button>
)}
</div>
{/* Floating: zoom controls (bottom-left) */}
<div className="absolute bottom-4 left-4">
<ZoomControls onTidy={tidyLayout} />
</div>
{/* Floating: hint (bottom-center) — fades once a node is selected */}
{!selectedService && services.length > 0 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 pointer-events-none hidden lg:block">
<p className="canvas-pill px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)] whitespace-nowrap">
Click to inspect · Double-click to open · Right-click for actions · F to fit
</p>
</div>
)}
{/* Inspector (right) */}
{selectedService && (
<ServiceInspector
service={selectedService}
variables={variablesByService[selectedService.id] ?? []}
connections={selectedConnections}
groups={inspectorGroups}
groupId={selectedNode?.parentId}
readOnly={readOnly}
actions={{
deploy: () => 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 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto">
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Rocket size={24} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
Add a service from a git repo or Docker image - it runs on a private network with the rest of this
project.
</p>
{!readOnly && (
<button
type="button"
onClick={onAddService}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
Add Service
</button>
)}
</div>
</div>
)}
{/* Context menus — service node, group frame, or empty pane */}
{contextMenu && (
<div
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] panel-glass py-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.kind === 'pane' && (
<>
{!readOnly && (
<>
<button
type="button"
onClick={() => {
setContextMenu(null);
onAddService();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Plus size={13} />
Add service
</button>
<button
type="button"
onClick={() => {
const at =
contextMenu.flowX !== undefined && contextMenu.flowY !== undefined
? { x: contextMenu.flowX, y: contextMenu.flowY }
: undefined;
setContextMenu(null);
addGroup(at);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Layers size={13} />
Add group here
</button>
</>
)}
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
onClick={() => {
setContextMenu(null);
tidyLayout();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Wand2 size={13} />
Tidy layout
</button>
<button
type="button"
onClick={() => {
setContextMenu(null);
void fitView({ padding: 0.18, duration: 240 });
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Maximize2 size={13} />
Fit view
</button>
</div>
</>
)}
{contextMenu.kind === 'group' && contextGroup && (
<>
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextGroup.data.title}</p>
<p className="text-[10px] text-[var(--text-tertiary)]">Group</p>
</div>
<button
type="button"
onClick={() => {
const id = contextGroup.id;
setContextMenu(null);
requestGroupRename(id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Pencil size={13} />
Rename
</button>
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
onClick={() => deleteGroup(contextGroup.id)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors"
>
<Ungroup size={13} />
Ungroup (keep services)
</button>
</div>
</>
)}
{contextMenu.kind === 'service' && contextService && (
<>
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
</div>
<button
type="button"
onClick={() => {
setContextMenu(null);
onOpenService(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<ArrowUpRight size={13} />
Open service
</button>
{(contextService.domain || contextService.publicUrl) && (
<a
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Globe size={13} />
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
</a>
)}
{!readOnly && (
<>
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
deployMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Rocket size={13} />
Deploy
</button>
{contextService.status !== 'running' && (
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
startMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Play size={13} />
Start
</button>
)}
<button
type="button"
disabled={actionPending || contextService.status !== 'running'}
onClick={() => {
setContextMenu(null);
restartMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Play size={13} />
Restart
</button>
<button
type="button"
disabled={actionPending || contextService.status !== 'running'}
onClick={() => {
setContextMenu(null);
stopMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Square size={13} />
Stop
</button>
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
deleteMutation.mutate(contextService.id);
}
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
>
<Trash2 size={13} />
Delete
</button>
</div>
</>
)}
</>
)}
</div>
)}
</div>
);
}
export function ProjectCanvas(props: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}