feat(canvas): fullscreen Railway-style workspace canvas

Rebuild the project canvas as a full-viewport graph instead of a boxed
66vh panel. Slim workspace top bar now holds the view tabs, project meta
and actions; the canvas fills all remaining space.

- Floating glass controls: env filter + group pill (top-left), zoom out /
  live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap
  (bottom-right), hint chip, demo badge.
- Inspector panel: selecting a node opens a right-side glass panel with
  status, image, address, inferred connections incl. variable reasons,
  and deploy/restart/stop/delete actions. Esc or pane click closes.
- Auto-layout: dependency-depth layered layout (consumers left,
  datastores right) for first-visit placement and the Tidy action.
  Unpositioned services park right of existing bounds, not at (0,0).
- Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type
  icons/colors incl. cron, spinner icon while deploying, hover lift.
- Edges: lime bezier strokes, animated dashes while either endpoint is
  deploying, hover glow updated from stale purple to accent.
- Groups: off-brand crimson replaced with accent tokens, double-click
  title to rename inline.
- Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select
  on Shift-drag. Double-click zoom disabled, dead connection drags off.
- Mobile: icon-only tabs, minimap/hint hidden under md.
- Retake docs/screenshots/canvas.png + site copy (1600x900 demo).

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-23 17:00:50 +02:00
1 parent 84b102a2c6
commit e57f0470eb
9 files changed
+950 -456

No files matched your search

@@ -1,11 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import { import {
Background, Background,
BackgroundVariant,
MarkerType, MarkerType,
MiniMap,
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
useNodesState, useNodesState,
useReactFlow, useReactFlow,
useViewport,
type Edge, type Edge,
type Node, type Node,
type NodeTypes, type NodeTypes,
@@ -29,13 +32,14 @@ import {
type ProjectCanvasMetadata, type ProjectCanvasMetadata,
} from '../model'; } from '../model';
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { serviceTypeColor, serviceTypeIcon } from './service-visuals';
import { import {
Plus, Plus,
Layers, Layers,
Maximize2, Maximize2,
RotateCcw, Minus,
Box,
Link2, Link2,
Play, Play,
Square, Square,
@@ -43,6 +47,9 @@ import {
Trash2, Trash2,
ArrowUpRight, ArrowUpRight,
Globe, Globe,
X,
Loader2,
Wand2,
} from 'lucide-react'; } from 'lucide-react';
type CanvasProps = { type CanvasProps = {
@@ -51,6 +58,7 @@ type CanvasProps = {
variablesByService: Record<string, ServiceVariable[]>; variablesByService: Record<string, ServiceVariable[]>;
onAddService: () => void; onAddService: () => void;
onOpenService: (serviceId: string) => void; onOpenService: (serviceId: string) => void;
readOnly?: boolean;
}; };
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>; type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
@@ -58,10 +66,9 @@ type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
type CanvasNode = ServiceCanvasNode | GroupCanvasNode; type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
type CanvasEdge = Edge<{ reasons: string[] }>; type CanvasEdge = Edge<{ reasons: string[] }>;
const SERVICE_NODE_WIDTH = 210;
const SERVICE_NODE_HEIGHT = 96;
const GROUP_DEFAULT_WIDTH = 340; const GROUP_DEFAULT_WIDTH = 340;
const GROUP_DEFAULT_HEIGHT = 230; const GROUP_DEFAULT_HEIGHT = 230;
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
const nodeTypes: NodeTypes = { const nodeTypes: NodeTypes = {
serviceNode: ServiceNode, serviceNode: ServiceNode,
@@ -74,12 +81,17 @@ type ContextMenuState = {
y: number; y: number;
} | null; } | null;
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] { function toFlowNodes(
metadata: ProjectCanvasMetadata,
services: ServiceEntity[],
onOpenService: CanvasProps['onOpenService'],
onRenameGroup: (groupId: string, title: string) => void,
): CanvasNode[] {
const groups = metadata.groups.map( const groups = metadata.groups.map(
(group): GroupCanvasNode => ({ (group): GroupCanvasNode => ({
id: group.id, id: group.id,
type: 'groupNode', type: 'groupNode',
data: { title: group.title }, data: { title: group.title, onRename: onRenameGroup },
position: group.position, position: group.position,
draggable: true, draggable: true,
selectable: true, selectable: true,
@@ -111,7 +123,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
draggable: true, draggable: true,
selectable: true, selectable: true,
style: { style: {
width: SERVICE_NODE_WIDTH, width: NODE_WIDTH,
}, },
}; };
}); });
@@ -122,6 +134,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
function toFlowEdges( function toFlowEdges(
links: ReturnType<typeof inferAutoConnections>, links: ReturnType<typeof inferAutoConnections>,
positionOf: (serviceId: string) => { x: number; y: number } | undefined, positionOf: (serviceId: string) => { x: number; y: number } | undefined,
statusOf: (serviceId: string) => string | undefined,
): CanvasEdge[] { ): CanvasEdge[] {
return links.map((link) => { return links.map((link) => {
const source = positionOf(link.edge.sourceServiceId); const source = positionOf(link.edge.sourceServiceId);
@@ -129,6 +142,9 @@ function toFlowEdges(
// Choose handle sides by relative position so edges flow forward // Choose handle sides by relative position so edges flow forward
// instead of looping back through the canvas. // instead of looping back through the canvas.
const forward = !source || !target || source.x <= target.x; 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 { return {
id: link.edge.id, id: link.edge.id,
@@ -136,19 +152,19 @@ function toFlowEdges(
target: link.edge.targetServiceId, target: link.edge.targetServiceId,
sourceHandle: forward ? 's-r' : 's-l', sourceHandle: forward ? 's-r' : 's-l',
targetHandle: forward ? 't-l' : 't-r', targetHandle: forward ? 't-l' : 't-r',
animated: false, animated: live,
data: { data: {
reasons: link.reasons, reasons: link.reasons,
}, },
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)', color: 'rgba(180, 227, 74, 0.55)',
width: 14, width: 13,
height: 14, height: 13,
}, },
style: { style: {
stroke: 'var(--accent-primary)', stroke: 'rgba(180, 227, 74, 0.42)',
strokeWidth: 2, strokeWidth: 1.6,
}, },
className: 'edge-premium', className: 'edge-premium',
}; };
@@ -190,7 +206,58 @@ function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: nu
}; };
} }
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService }: CanvasProps) { 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, readOnly }: CanvasProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null); const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false); const hydratedRef = useRef(false);
@@ -203,7 +270,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); const toast = useToast();
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>(); const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow<CanvasNode, CanvasEdge>();
const environments = useMemo(() => { const environments = useMemo(() => {
const envs = new Set(services.map((service) => service.environment ?? 'production')); const envs = new Set(services.map((service) => service.environment ?? 'production'));
@@ -226,10 +293,22 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
); );
const edges = useMemo(() => { const edges = useMemo(() => {
const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position; const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
return toFlowEdges(inferredLinks, positionOf).filter( 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), (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
); );
}, [inferredLinks, visibleIds, nodes]); }, [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(() => { const invalidateServices = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
@@ -276,6 +355,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
onSuccess: () => { onSuccess: () => {
toast.showToast('Service deleted', 'success'); toast.showToast('Service deleted', 'success');
invalidateServices(); invalidateServices();
setSelectedServiceId(null);
}, },
onError: actionError, onError: actionError,
}); });
@@ -286,10 +366,28 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
stopMutation.isPending || stopMutation.isPending ||
deleteMutation.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(() => { useEffect(() => {
const hasStoredViewport = hasCanvasMetadata(projectId); const hasStoredViewport = hasCanvasMetadata(projectId);
const metadata = loadCanvasMetadata(projectId, services); const layout = hasStoredViewport ? undefined : layoutFromVariables(services, variablesByService);
const nextNodes = toFlowNodes(metadata, services, onOpenService); const metadata = loadCanvasMetadata(projectId, services, layout);
const nextNodes = toFlowNodes(metadata, services, onOpenService, renameGroup);
viewportRestoredRef.current = false; viewportRestoredRef.current = false;
setNodes(nextNodes); setNodes(nextNodes);
@@ -302,7 +400,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom; metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
if (!hasStoredViewport || isDefaultViewport) { if (!hasStoredViewport || isDefaultViewport) {
void fitView({ padding: 0.15, duration: 120 }); void fitView({ padding: 0.18, duration: 120 });
} else { } else {
void setViewport(metadata.viewport, { duration: 120 }); void setViewport(metadata.viewport, { duration: 120 });
} }
@@ -311,7 +409,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}); });
hydratedRef.current = true; hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]); }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services, renameGroup, variablesByService]);
useEffect(() => { useEffect(() => {
if (!hydratedRef.current || !viewportRestoredRef.current) { if (!hydratedRef.current || !viewportRestoredRef.current) {
@@ -352,6 +450,47 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
); );
}, [selectedServiceId, setNodes]); }, [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) => { const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => {
for (const node of nodes) { for (const node of nodes) {
if (node.type !== 'groupNode' || node.id === ignoreGroupId) { if (node.type !== 'groupNode' || node.id === ignoreGroupId) {
@@ -377,8 +516,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
} }
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position; const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2; const centerX = basePosition.x + NODE_WIDTH / 2;
const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2; const centerY = basePosition.y + NODE_HEIGHT / 2;
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId); const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
if (targetGroup) { if (targetGroup) {
@@ -386,8 +525,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH; const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH;
const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT; const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT;
const relativeX = Math.max(12, Math.min(targetWidth - SERVICE_NODE_WIDTH - 12, basePosition.x - groupBase.x)); const relativeX = Math.max(12, Math.min(targetWidth - NODE_WIDTH - 12, basePosition.x - groupBase.x));
const relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y)); const relativeY = Math.max(30, Math.min(targetHeight - NODE_HEIGHT - 12, basePosition.y - groupBase.y));
setNodes((current) => setNodes((current) =>
current.map((node) => { current.map((node) => {
@@ -452,7 +591,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
x: center.x - GROUP_DEFAULT_WIDTH / 2, x: center.x - GROUP_DEFAULT_WIDTH / 2,
y: center.y - GROUP_DEFAULT_HEIGHT / 2, y: center.y - GROUP_DEFAULT_HEIGHT / 2,
}, },
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` }, data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup },
draggable: true, draggable: true,
selectable: true, selectable: true,
style: { style: {
@@ -461,7 +600,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}, },
} satisfies GroupCanvasNode, } satisfies GroupCanvasNode,
]); ]);
}, [screenToFlowPosition, setNodes]); }, [screenToFlowPosition, setNodes, renameGroup]);
// Env filter hides nodes via React Flow's `hidden` flag — positions and // Env filter hides nodes via React Flow's `hidden` flag — positions and
// persisted layout stay intact for services outside the current filter. // persisted layout stay intact for services outside the current filter.
@@ -493,281 +632,412 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null; const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null; const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
const selectedServiceLinkCount = selectedService const selectedConnections = useMemo(() => {
? inferredLinks.filter( if (!selectedService) {
return [];
}
const nameOf = (id: string) => services.find((service) => service.id === id)?.name ?? id;
return inferredLinks
.filter(
(link) => (link) =>
link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id, link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id,
).length )
: 0; .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 serviceTypeColor((node.data as ServiceNodeData).service.type);
},
[],
);
return ( return (
<div className="panel overflow-hidden"> <div ref={wrapperRef} className="relative h-full min-h-0 w-full bg-[var(--bg-void)]">
{/* Toolbar - Railway-inspired premium design */} <ReactFlow
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3"> nodes={renderedNodes}
<button edges={edges}
type="button" nodeTypes={nodeTypes}
onClick={onAddService} onNodesChange={onNodesChange}
className="flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-200" onPaneClick={() => {
style={{ background: 'var(--accent-primary)' }} setSelectedServiceId(null);
> setContextMenu(null);
<Plus size={15} /> }}
Add Service onMoveStart={() => setContextMenu(null)}
</button> onNodeClick={(_event, node) => {
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" /> setContextMenu(null);
if (node.type === 'serviceNode') {
setSelectedServiceId(node.id);
}
}}
onNodeDoubleClick={(_event, node) => {
if (node.type === 'serviceNode') {
onOpenService(node.id);
}
}}
onNodeContextMenu={onNodeContextMenu}
onNodeDragStop={onNodeDragStop}
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>
)}
<button <button
type="button" type="button"
onClick={addGroup} onClick={addGroup}
className="flex items-center gap-2 h-9 px-3 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] hover:border-[var(--border-default)] transition-all" 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={14} /> <Layers size={13} />
Group Group
</button> </button>
{environments.length > 1 && (
<>
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
{['all', ...environments].map((env) => (
<button
key={env}
type="button"
onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-md text-xs 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>
</>
)}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => fitView({ padding: 0.2, duration: 240 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Fit View"
>
<Maximize2 size={16} />
</button>
<button
type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Reset View"
>
<RotateCcw size={16} />
</button>
</div>
<div className="flex items-center gap-4 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Box size={12} />
<span className="font-medium">{services.length}</span>
</div>
<div className="w-px h-3 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Link2 size={12} />
<span className="font-medium">{edges.length}</span>
</div>
</div>
</div> </div>
{/* Canvas */} {/* Floating: zoom controls (bottom-left) */}
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative"> <div className="absolute bottom-4 left-4">
<ReactFlow <ZoomControls onTidy={tidyLayout} />
nodes={renderedNodes} </div>
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}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
panOnDrag
zoomOnScroll
minZoom={0.25}
maxZoom={2.3}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
{/* SVG Definitions for premium edge styling */}
<svg style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
<defs />
</svg>
<Background color="rgba(255,255,255,0.05)" gap={28} />
</ReactFlow>
{/* Empty state */} {/* Floating: hint (bottom-center) — fades once a node is selected */}
{services.length === 0 && ( {!selectedService && services.length > 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 pointer-events-none hidden lg:block">
<div className="text-center pointer-events-auto"> <p className="canvas-pill px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)] whitespace-nowrap">
<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"> Click to inspect · Double-click to open · Right-click for actions · F to fit
<Rocket size={24} className="text-[var(--accent-primary)]" /> </p>
</div> </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 {/* Inspector (right) */}
project. {selectedService && (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[300px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${serviceTypeColor(selectedService.type)}1c`, color: serviceTypeColor(selectedService.type) }}
>
{serviceTypeIcon(selectedService.type, 16)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{selectedService.name}</h3>
<p className="text-[11px] text-[var(--text-tertiary)]">
{selectedService.type} · {selectedService.environment ?? 'production'}
</p> </p>
<button </div>
type="button" <button
onClick={onAddService} type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg" onClick={() => setSelectedServiceId(null)}
style={{ background: 'var(--accent-primary)' }} className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
> title="Close (Esc)"
<Plus size={15} /> >
Add Service <X size={14} />
</button> </button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{selectedService.image && (
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Image</p>
<p className="mono text-[11px] text-[var(--text-secondary)] break-all">{selectedService.image}</p>
</div>
)}
{(selectedService.domain || selectedService.publicUrl || selectedService.port) && (
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Address</p>
{selectedService.domain || selectedService.publicUrl ? (
<a
href={selectedService.domain ? `https://${selectedService.domain}` : selectedService.publicUrl}
target="_blank"
rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
>
{(selectedService.domain ?? selectedService.publicUrl ?? '').replace(/^https?:\/\//, '')}
</a>
) : null}
{selectedService.port ? (
<p className="mono text-[11px] text-[var(--text-secondary)] mt-0.5">
{selectedService.name}:{selectedService.port}
</p>
) : null}
</div>
)}
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Connections ({selectedConnections.length})
</p>
{selectedConnections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via <span className="mono">{'{{variable}}'}</span> placeholders in env vars.
</p>
) : (
<div className="space-y-1.5">
{selectedConnections.map((conn) => (
<button
key={conn.id}
type="button"
onClick={() => setSelectedServiceId(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')}
</p>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
)}
{/* Node context menu */} <div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
{contextMenu && contextService && ( <button
<div type="button"
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1" onClick={() => onOpenService(selectedService.id)}
style={{ left: contextMenu.x, top: contextMenu.y }} className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
> style={{ background: 'var(--accent-primary)' }}
<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> <ArrowUpRight size={14} />
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p> Open service
</button>
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
disabled={actionPending || readOnly}
onClick={() => deployMutation.mutate(selectedService.id)}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{deployMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy
</button>
{selectedService.status !== 'running' ? (
<button
type="button"
disabled={actionPending || readOnly}
onClick={() => startMutation.mutate(selectedService.id)}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Play size={11} />
Start
</button>
) : (
<button
type="button"
disabled={actionPending || readOnly}
onClick={() => restartMutation.mutate(selectedService.id)}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{restartMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart
</button>
)}
<button
type="button"
disabled={actionPending || readOnly || selectedService.status !== 'running'}
onClick={() => stopMutation.mutate(selectedService.id)}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Square size={11} />
Stop
</button>
</div> </div>
<button <button
type="button" type="button"
disabled={actionPending || readOnly}
onClick={() => { onClick={() => {
setContextMenu(null); if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) {
onOpenService(contextService.id); deleteMutation.mutate(selectedService.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" className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
> >
<ArrowUpRight size={13} /> <Trash2 size={11} />
Open service Delete service
</button> </button>
{(contextService.domain || contextService.publicUrl) && ( </div>
<a </aside>
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl} )}
target="_blank"
rel="noopener noreferrer" {/* Empty state */}
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" {services.length === 0 && (
> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<Globe size={13} /> <div className="text-center pointer-events-auto">
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} <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">
</a> <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>
<button <button
type="button" type="button"
disabled={actionPending} onClick={onAddService}
onClick={() => { className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
setContextMenu(null); style={{ background: 'var(--accent-primary)' }}
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} /> <Plus size={15} />
Deploy Add Service
</button> </button>
{contextService.status !== 'running' && ( </div>
</div>
)}
{/* Node context menu */}
{contextMenu && contextService && (
<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 }}
>
<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 <button
type="button" type="button"
disabled={actionPending} disabled={actionPending}
onClick={() => { onClick={() => {
setContextMenu(null); setContextMenu(null);
startMutation.mutate(contextService.id); 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" 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} /> <Play size={13} />
Start Restart
</button> </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 <button
type="button" type="button"
disabled={actionPending} disabled={actionPending || contextService.status !== 'running'}
onClick={() => { onClick={() => {
setContextMenu(null); setContextMenu(null);
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) { stopMutation.mutate(contextService.id);
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" 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"
> >
<Trash2 size={13} /> <Square size={13} />
Delete Stop
</button> </button>
</div> <div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
</div> <button
)} type="button"
</div> disabled={actionPending}
onClick={() => {
{/* Footer */} setContextMenu(null);
<div className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3"> if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
{selectedService ? ( deleteMutation.mutate(contextService.id);
<div className="flex items-center justify-between"> }
<div className="flex items-center gap-3"> }}
<div className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center"> 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"
<Box size={14} className="text-[var(--accent-primary)]" /> >
<Trash2 size={13} />
Delete
</button>
</div> </div>
<div> </>
<span className="text-sm font-medium text-[var(--text-primary)]">{selectedService.name}</span> )}
<span className="ml-2 text-xs text-[var(--text-tertiary)]">({selectedService.type})</span> </div>
</div> )}
</div>
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--surface-muted)] text-xs text-[var(--text-tertiary)]">
<Link2 size={10} />
<span>{selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''}</span>
</div>
</div>
) : (
<p className="text-xs text-[var(--text-tertiary)] text-center">
Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
</p>
)}
</div>
</div> </div>
); );
} }
@@ -0,0 +1,109 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { inferAutoConnections } from '../auto-connections';
export const NODE_WIDTH = 240;
export const NODE_HEIGHT = 104;
export const COL_GAP = 340;
export const ROW_GAP = 172;
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
// Layered left-to-right layout: a service's depth is the longest chain of
// dependencies beneath it, so consumers sit left and datastores right.
// Unconnected services get their own trailing column separated by a gap.
export function computeLayeredLayout(
services: ServiceEntity[],
edges: LinkEdge[],
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
if (services.length === 0) {
return positions;
}
const ids = new Set(services.map((service) => service.id));
const outgoing = new Map<string, string[]>();
const connected = new Set<string>();
for (const edge of edges) {
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
continue;
}
connected.add(edge.sourceServiceId);
connected.add(edge.targetServiceId);
const list = outgoing.get(edge.sourceServiceId) ?? [];
list.push(edge.targetServiceId);
outgoing.set(edge.sourceServiceId, list);
}
const depthMemo = new Map<string, number>();
const depthOf = (id: string, trail: Set<string>): number => {
const memoized = depthMemo.get(id);
if (memoized !== undefined) {
return memoized;
}
if (trail.has(id)) {
return 0; // cycle guard
}
trail.add(id);
let best = 0;
for (const target of outgoing.get(id) ?? []) {
best = Math.max(best, depthOf(target, trail) + 1);
}
trail.delete(id);
depthMemo.set(id, best);
return best;
};
const connectedServices = services
.filter((service) => connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const looseServices = services
.filter((service) => !connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
const maxDepth = Math.max(0, ...depthById.values());
// Column = maxDepth - depth so deeper consumers render further left.
const columns = new Map<number, ServiceEntity[]>();
for (const service of connectedServices) {
const col = maxDepth - (depthById.get(service.id) ?? 0);
const list = columns.get(col) ?? [];
list.push(service);
columns.set(col, list);
}
const connectedColCount = columns.size;
const maxRows = Math.max(
1,
...Array.from(columns.values()).map((list) => list.length),
looseServices.length,
);
for (const [col, list] of columns) {
const offsetY = ((maxRows - list.length) * ROW_GAP) / 2;
list.forEach((service, row) => {
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
}
looseServices.forEach((service, row) => {
const col = connectedColCount > 0 ? connectedColCount : 0;
const offsetY = ((maxRows - looseServices.length) * ROW_GAP) / 2;
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
return positions;
}
export function layoutFromVariables(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): Map<string, { x: number; y: number }> {
const links = inferAutoConnections(services, variablesByService);
return computeLayeredLayout(
services,
links.map((link) => link.edge),
);
}
@@ -1,8 +1,9 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client';
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { serviceTypeColor, serviceTypeIcon } from './service-visuals';
export type ServiceNodeData = { export type ServiceNodeData = {
service: ServiceEntity; service: ServiceEntity;
@@ -12,37 +13,12 @@ export type ServiceNodeData = {
export type GroupNodeData = { export type GroupNodeData = {
title: string; title: string;
onRename?: (groupId: string, title: string) => void;
}; };
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>; export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>; export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function renderServiceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
default:
return <Box size={size} />;
}
}
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff';
case 'database':
return '#b4e34a';
case 'worker':
return 'var(--accent-primary)';
default:
return '#9295a4';
}
}
function statusLabel(status: string): string { function statusLabel(status: string): string {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -72,27 +48,29 @@ function statusColor(status: string): string {
} }
} }
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) { export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected } = data; const { service, selected } = data;
const typeColor = serviceTypeColor(service.type); const typeColor = serviceTypeColor(service.type);
const replicas = service.replicas ?? 0; const replicas = service.replicas ?? 0;
const stateColor = statusColor(service.status); const stateColor = statusColor(service.status);
const isTransient = TRANSIENT_STATUSES.has(service.status);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
return ( return (
<div <div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${ className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`} }`}
style={{ minWidth: 220, maxWidth: 280 }}
> >
<div <div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }} style={{ background: `${typeColor}08` }}
/> />
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
@@ -105,22 +83,22 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative p-4"> <div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-center gap-3 mb-2.5">
<div <div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${ className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{ style={{
background: selected ? typeColor : `${typeColor}20`, background: selected ? typeColor : `${typeColor}1c`,
color: selected ? 'white' : typeColor, color: selected ? 'var(--bg-void)' : typeColor,
}} }}
> >
{renderServiceTypeIcon(service.type, 18)} {isTransient ? <Loader2 size={16} className="animate-spin" /> : serviceTypeIcon(service.type, 16)}
</div> </div>
<div className="min-w-0 flex-1 pt-0.5"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4> <h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5"> {service.name}
</h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type} {service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p> </p>
@@ -133,7 +111,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
)} )}
</div> </div>
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr"> <div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
@@ -141,12 +119,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline" className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address"> <span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr} {internalAddr}
</span> </span>
)} )}
@@ -164,14 +142,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}> <span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)} {statusLabel(service.status)}
</span> </span>
</div> </div>
{service.image && ( {service.image && (
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}> <span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
{service.image} {service.image}
</span> </span>
)} )}
@@ -181,41 +159,68 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
); );
} }
export function GroupNode({ data }: NodeProps<GroupNodeType>) { export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(data.title);
const commit = () => {
const next = draft.trim() || data.title;
setEditing(false);
if (next !== data.title) {
data.onRename?.(id, next);
}
};
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden" className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{ style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)', borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30', background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0,
}}
/>
<div className="relative p-4"> <div className="relative p-4">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2.5">
<div className="flex items-center gap-2.5"> <div
<div className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" style={{
style={{ background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)', color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)', }}
}} >
> <Box size={14} />
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
</div> </div>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setDraft(data.title);
setEditing(false);
}
}}
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
/>
) : (
<h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setDraft(data.title);
setEditing(true);
}}
title="Double-click to rename"
>
{data.title}
</h3>
)}
</div> </div>
<p <p
@@ -0,0 +1,31 @@
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
export function serviceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
case 'cron':
return <Clock size={size} />;
default:
return <Box size={size} />;
}
}
export function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff';
case 'database':
return '#b4e34a';
case 'worker':
return '#f2c94c';
case 'cron':
return '#f2994a';
default:
return '#9295a4';
}
}
+5 -2
View File
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
zoom: 1, zoom: 1,
}; };
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata { export function createDefaultCanvasMetadata(
services: ServiceEntity[],
layout?: Map<string, CanvasPoint>,
): ProjectCanvasMetadata {
const nodes = services.map((service, index) => { const nodes = services.map((service, index) => {
const col = index % 3; const col = index % 3;
const row = Math.floor(index / 3); const row = Math.floor(index / 3);
return { return {
serviceId: service.id, serviceId: service.id,
position: { position: layout?.get(service.id) ?? {
x: 70 + col * 260, x: 70 + col * 260,
y: 80 + row * 170, y: 80 + row * 170,
}, },
@@ -654,131 +654,113 @@ export function ProjectWorkspacePage() {
); );
} }
const canvasView = activeView === 'canvas';
return ( return (
<div className="min-h-screen"> <div className="flex h-full min-h-0 flex-col">
{/* Header */} {/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
<div className="w-full px-8 py-4"> <button
<div className="flex items-center justify-between gap-4"> onClick={() => navigate('/projects')}
<div className="flex items-center gap-4"> className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
>
<ArrowLeft size={14} />
<span className="hidden sm:inline">Projects</span>
</button>
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span>
{/* View tabs */}
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button <button
onClick={() => navigate('/projects')} key={item.key}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
<ArrowLeft size={16} /> <Icon size={13} />
<span>Projects</span> <span className="hidden md:inline">{item.label}</span>
</button> </button>
<div className="w-px h-5 bg-[var(--border-subtle)]" /> );
<div> })}
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1> </nav>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
<div className="flex items-center gap-6"> <div className="ml-auto flex items-center gap-2 shrink-0">
{/* Stats */} <div className="hidden xl:block">
<div className="hidden md:flex items-center gap-6"> <LiveIndicator isLive={runningServices > 0} />
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={openAddService}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div> </div>
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<Clock size={11} />
{formatRelative(project.updatedAt)}
</span>
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{!isDemoMode && (
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div> </div>
</div> </div>
{/* Demo Mode Banner */} {/* Content */}
{isDemoMode && ( {canvasView ? (
<div className="w-full px-8 py-4"> <div className="relative flex-1 min-h-0">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <ProjectCanvas
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> projectId={project.id}
<Sparkles size={16} /> services={services}
<span>Demo mode active — using sample data for preview</span> variablesByService={variablesByService}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
readOnly={isDemoMode}
/>
{isDemoMode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
<Sparkles size={12} />
Demo data
</span>
</div>
)}
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && (
<div className="w-full px-8 pt-6">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active - using sample data for preview</span>
</div>
</div> </div>
</div> </div>
</div> )}
)} <div className="w-full px-8 py-6">
{/* Main Content */}
<div className="w-full px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{item.label}</span>
{active && (
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
)}
</button>
);
})}
</nav>
</aside>
{/* Content Area */}
<section className="min-w-0"> <section className="min-w-0">
{activeView === 'canvas' && (
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
{activeView === 'observability' && ( {activeView === 'observability' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Health Summary */} {/* Health Summary */}
@@ -965,6 +947,7 @@ export function ProjectWorkspacePage() {
</section> </section>
</div> </div>
</div> </div>
)}
{/* Create Service Dialog */} {/* Create Service Dialog */}
{!isDemoMode && ( {!isDemoMode && (
+19 -9
View File
@@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata)); canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
} }
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata { export function loadCanvasMetadata(
projectId: string,
services: ServiceEntity[],
layout?: Map<string, { x: number; y: number }>,
): ProjectCanvasMetadata {
const key = canvasStorageKey(projectId); const key = canvasStorageKey(projectId);
const fallback = createDefaultCanvasMetadata(services); const fallback = createDefaultCanvasMetadata(services, layout);
const serviceIds = new Set(services.map((service) => service.id)); const serviceIds = new Set(services.map((service) => service.id));
const raw = canvasStorage()?.getItem(key); const raw = canvasStorage()?.getItem(key);
@@ -195,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node])); const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
const normalizedNodes: CanvasNodeLayout[] = []; const normalizedNodes: CanvasNodeLayout[] = [];
for (const [index, service] of services.entries()) { // Services added after the layout was saved park in a trailing column to
// the right of everything placed so far, instead of overlapping nodes the
// user already arranged.
const placedXs = parsed.nodes.map((node) => node.position.x);
let parkedRow = 0;
const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70;
for (const service of services) {
const existing = nodeMap.get(service.id); const existing = nodeMap.get(service.id);
if (existing) { if (existing) {
normalizedNodes.push({ normalizedNodes.push({
@@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
continue; continue;
} }
const col = index % 3; const parked = layout?.get(service.id);
const row = Math.floor(index / 3);
normalizedNodes.push({ normalizedNodes.push({
serviceId: service.id, serviceId: service.id,
position: { position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
x: 70 + col * 260,
y: 80 + row * 170,
},
}); });
if (!parked) {
parkedRow += 1;
}
} }
const seenEdgeIds = new Set<string>(); const seenEdgeIds = new Set<string>();
+86 -3
View File
@@ -634,13 +634,13 @@ textarea {
.edge-premium .react-flow__edge-path { .edge-premium .react-flow__edge-path {
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgba(156, 126, 240, 0.3)); filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25));
transition: filter 0.2s ease, stroke-width 0.2s ease; transition: filter 0.2s ease, stroke-width 0.2s ease;
} }
.edge-premium:hover .react-flow__edge-path { .edge-premium:hover .react-flow__edge-path {
filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5)); filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45));
stroke-width: 3px; stroke-width: 2.6px;
} }
/* ============================================ /* ============================================
@@ -1685,3 +1685,86 @@ textarea {
margin: 24px 0; margin: 24px 0;
} }
.docs-article img { max-width: 100%; border-radius: var(--radius-md); } .docs-article img { max-width: 100%; border-radius: var(--radius-md); }
/* ============================================
CANVAS — fullscreen workspace graph
============================================ */
/* Floating glass pills (zoom controls, env filter, hints) */
.canvas-pill {
display: inline-flex;
align-items: center;
background: rgba(19, 21, 24, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-default);
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.canvas-pill-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 6px;
border-radius: 999px;
color: var(--text-tertiary);
transition: color 0.15s ease, background 0.15s ease;
}
.canvas-pill-btn:hover {
color: var(--text-primary);
background: var(--surface-muted);
}
/* React Flow pane affordances */
.react-flow__pane {
cursor: grab;
}
.react-flow__pane.dragging {
cursor: grabbing;
}
.react-flow__node.selectable:focus {
outline: none;
}
/* Marquee selection tint */
.react-flow__selection {
background: var(--accent-primary-soft);
border: 1px solid var(--border-strong);
}
/* MiniMap — match surface card, dimmed nodes */
.react-flow__minimap {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border-default);
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.react-flow__minimap-svg {
display: block;
}
/* Inspector panel entrance */
.canvas-inspector {
animation: canvas-panel-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes canvas-panel-in {
from {
opacity: 0;
transform: translateX(14px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.canvas-inspector {
animation: none;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 145 KiB