diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index 53f0aef..bc70fb8 100644 --- a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx +++ b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx @@ -1,11 +1,14 @@ 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, @@ -29,13 +32,14 @@ import { type ProjectCanvasMetadata, } from '../model'; 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 { serviceTypeColor, serviceTypeIcon } from './service-visuals'; import { Plus, Layers, Maximize2, - RotateCcw, - Box, + Minus, Link2, Play, Square, @@ -43,6 +47,9 @@ import { Trash2, ArrowUpRight, Globe, + X, + Loader2, + Wand2, } from 'lucide-react'; type CanvasProps = { @@ -51,6 +58,7 @@ type CanvasProps = { variablesByService: Record; onAddService: () => void; onOpenService: (serviceId: string) => void; + readOnly?: boolean; }; type ServiceCanvasNode = Node; @@ -58,10 +66,9 @@ type GroupCanvasNode = Node; type CanvasNode = ServiceCanvasNode | GroupCanvasNode; type CanvasEdge = Edge<{ reasons: string[] }>; -const SERVICE_NODE_WIDTH = 210; -const SERVICE_NODE_HEIGHT = 96; const GROUP_DEFAULT_WIDTH = 340; const GROUP_DEFAULT_HEIGHT = 230; +const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']); const nodeTypes: NodeTypes = { serviceNode: ServiceNode, @@ -74,12 +81,17 @@ type ContextMenuState = { y: number; } | 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( (group): GroupCanvasNode => ({ id: group.id, type: 'groupNode', - data: { title: group.title }, + data: { title: group.title, onRename: onRenameGroup }, position: group.position, draggable: true, selectable: true, @@ -111,7 +123,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], draggable: true, selectable: true, style: { - width: SERVICE_NODE_WIDTH, + width: NODE_WIDTH, }, }; }); @@ -122,6 +134,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], function toFlowEdges( links: ReturnType, positionOf: (serviceId: string) => { x: number; y: number } | undefined, + statusOf: (serviceId: string) => string | undefined, ): CanvasEdge[] { return links.map((link) => { const source = positionOf(link.edge.sourceServiceId); @@ -129,6 +142,9 @@ function toFlowEdges( // 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, @@ -136,19 +152,19 @@ function toFlowEdges( target: link.edge.targetServiceId, sourceHandle: forward ? 's-r' : 's-l', targetHandle: forward ? 't-l' : 't-r', - animated: false, + animated: live, data: { reasons: link.reasons, }, markerEnd: { type: MarkerType.ArrowClosed, - color: 'var(--accent-secondary)', - width: 14, - height: 14, + color: 'rgba(180, 227, 74, 0.55)', + width: 13, + height: 13, }, style: { - stroke: 'var(--accent-primary)', - strokeWidth: 2, + stroke: 'rgba(180, 227, 74, 0.42)', + strokeWidth: 1.6, }, 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 ( +
+ + + +
+ + +
+ ); +} + +function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, readOnly }: CanvasProps) { const wrapperRef = useRef(null); const persistTimeout = useRef(null); const hydratedRef = useRef(false); @@ -203,7 +270,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on const queryClient = useQueryClient(); const toast = useToast(); - const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow(); + const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow(); const environments = useMemo(() => { 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 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), ); - }, [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(() => { void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); @@ -276,6 +355,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on onSuccess: () => { toast.showToast('Service deleted', 'success'); invalidateServices(); + setSelectedServiceId(null); }, onError: actionError, }); @@ -286,10 +366,28 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on 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 metadata = loadCanvasMetadata(projectId, services); - const nextNodes = toFlowNodes(metadata, services, onOpenService); + 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); @@ -302,7 +400,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom; if (!hasStoredViewport || isDefaultViewport) { - void fitView({ padding: 0.15, duration: 120 }); + void fitView({ padding: 0.18, duration: 120 }); } else { void setViewport(metadata.viewport, { duration: 120 }); } @@ -311,7 +409,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on }); hydratedRef.current = true; - }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]); + }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services, renameGroup, variablesByService]); useEffect(() => { if (!hydratedRef.current || !viewportRestoredRef.current) { @@ -352,6 +450,47 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on ); }, [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) { @@ -377,8 +516,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on } const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position; - const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2; - const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2; + const centerX = basePosition.x + NODE_WIDTH / 2; + const centerY = basePosition.y + NODE_HEIGHT / 2; const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId); 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 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 relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y)); + const relativeX = Math.max(12, Math.min(targetWidth - NODE_WIDTH - 12, basePosition.x - groupBase.x)); + const relativeY = Math.max(30, Math.min(targetHeight - NODE_HEIGHT - 12, basePosition.y - groupBase.y)); setNodes((current) => current.map((node) => { @@ -452,7 +591,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on 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}` }, + data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup }, draggable: true, selectable: true, style: { @@ -461,7 +600,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on }, } satisfies GroupCanvasNode, ]); - }, [screenToFlowPosition, setNodes]); + }, [screenToFlowPosition, setNodes, renameGroup]); // Env filter hides nodes via React Flow's `hidden` flag — positions and // 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 selectedService = services.find((service) => service.id === selectedServiceId) ?? null; - const selectedServiceLinkCount = selectedService - ? inferredLinks.filter( + 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, - ).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 ( -
- {/* Toolbar - Railway-inspired premium design */} -
- -
+
+ { + 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 + zoomOnDoubleClick={false} + nodesConnectable={false} + nodeDragThreshold={3} + onlyRenderVisibleElements + minZoom={0.2} + maxZoom={2.2} + deleteKeyCode={null} + selectionKeyCode="Shift" + proOptions={{ hideAttribution: true }} + > + + + + + {/* Floating: env filter + group (top-left) */} +
+ {environments.length > 1 && ( +
+ {['all', ...environments].map((env) => ( + + ))} +
+ )} - - {environments.length > 1 && ( - <> -
-
- {['all', ...environments].map((env) => ( - - ))} -
- - )} - -
- - -
- -
-
- - {services.length} -
-
-
- - {edges.length} -
-
- {/* Canvas */} -
- { - 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 */} - - - - - + {/* Floating: zoom controls (bottom-left) */} +
+ +
- {/* Empty state */} - {services.length === 0 && ( -
-
-
- -
-

Deploy your first service

-

- Add a service from a git repo or Docker image — it runs on a private network with the rest of this - project. + {/* Floating: hint (bottom-center) — fades once a node is selected */} + {!selectedService && services.length > 0 && ( +

+

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

+
+ )} + + {/* Inspector (right) */} + {selectedService && ( + + )} + + {/* Empty state */} + {services.length === 0 && ( +
+
+
+ +
+

Deploy your first service

+

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

- {contextService.status !== 'running' && ( +
+
+ )} + + {/* Node context menu */} + {contextMenu && contextService && ( +
+
+

{contextService.name}

+

{contextService.status}

+
+ + {(contextService.domain || contextService.publicUrl) && ( + + + Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} + + )} + {!readOnly && ( + <> + {contextService.status !== 'running' && ( + + )} + - )} - - -
-
-
- )} -
- - {/* Footer */} -
- {selectedService ? ( -
-
-
- +
+
-
- {selectedService.name} - ({selectedService.type}) -
-
-
- - {selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''} -
-
- ) : ( -

- Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables -

- )} -
+ + )} +
+ )}
); } diff --git a/app/frontend/src/features/workspace/canvas/auto-layout.ts b/app/frontend/src/features/workspace/canvas/auto-layout.ts new file mode 100644 index 0000000..8b26e9f --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/auto-layout.ts @@ -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 { + const positions = new Map(); + if (services.length === 0) { + return positions; + } + + const ids = new Set(services.map((service) => service.id)); + const outgoing = new Map(); + const connected = new Set(); + + 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(); + const depthOf = (id: string, trail: Set): 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(); + 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, +): Map { + const links = inferAutoConnections(services, variablesByService); + return computeLayeredLayout( + services, + links.map((link) => link.edge), + ); +} diff --git a/app/frontend/src/features/workspace/canvas/nodes.tsx b/app/frontend/src/features/workspace/canvas/nodes.tsx index 942babf..8203097 100644 --- a/app/frontend/src/features/workspace/canvas/nodes.tsx +++ b/app/frontend/src/features/workspace/canvas/nodes.tsx @@ -1,8 +1,9 @@ import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import type { ServiceEntity } 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 { serviceTypeColor, serviceTypeIcon } from './service-visuals'; export type ServiceNodeData = { service: ServiceEntity; @@ -12,37 +13,12 @@ export type ServiceNodeData = { export type GroupNodeData = { title: string; + onRename?: (groupId: string, title: string) => void; }; export type ServiceNodeType = Node; export type GroupNodeType = Node; -function renderServiceTypeIcon(type: string, size = 18) { - switch (type) { - case 'web': - return ; - case 'database': - return ; - case 'worker': - return ; - default: - return ; - } -} - -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 { switch (status) { 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) { const { service, selected } = data; const typeColor = serviceTypeColor(service.type); const replicas = service.replicas ?? 0; const stateColor = statusColor(service.status); + const isTransient = TRANSIENT_STATUSES.has(service.status); const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; return (
@@ -105,22 +83,22 @@ export function ServiceNode({ data }: NodeProps) { style={{ background: stateColor }} /> -
-
+
+
- {renderServiceTypeIcon(service.type, 18)} + {isTransient ? : serviceTypeIcon(service.type, 16)}
-
-

{service.name}

-

+

+

+ {service.name} +

+

{service.type} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}

@@ -133,7 +111,7 @@ export function ServiceNode({ data }: NodeProps) { )}
-
+
-
+
- + {statusLabel(service.status)}
{service.image && ( - + {service.image} )} @@ -181,41 +159,68 @@ export function ServiceNode({ data }: NodeProps) { ); } -export function GroupNode({ data }: NodeProps) { +export function GroupNode({ id, data }: NodeProps) { 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 (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
-
-
-
-
- -
-

{data.title}

+
+
+
+ {editing ? ( + 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)]" + /> + ) : ( +

{ + e.stopPropagation(); + setDraft(data.title); + setEditing(true); + }} + title="Double-click to rename" + > + {data.title} +

+ )}

; + case 'database': + return ; + case 'worker': + return ; + case 'cron': + return ; + default: + return ; + } +} + +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'; + } +} diff --git a/app/frontend/src/features/workspace/model.ts b/app/frontend/src/features/workspace/model.ts index 704d479..950ad02 100644 --- a/app/frontend/src/features/workspace/model.ts +++ b/app/frontend/src/features/workspace/model.ts @@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = { zoom: 1, }; -export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata { +export function createDefaultCanvasMetadata( + services: ServiceEntity[], + layout?: Map, +): ProjectCanvasMetadata { const nodes = services.map((service, index) => { const col = index % 3; const row = Math.floor(index / 3); return { serviceId: service.id, - position: { + position: layout?.get(service.id) ?? { x: 70 + col * 260, y: 80 + row * 170, }, diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 62d35a2..6c3b8e8 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -654,131 +654,113 @@ export function ProjectWorkspacePage() { ); } + const canvasView = activeView === 'canvas'; + return ( -

- {/* Header */} -
-
-
-
+
+ {/* Slim workspace bar — tabs + project meta, canvas fills everything below */} +
+ +
+

{project.name}_

+ + {services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running + + + {/* View tabs */} + -
- {/* Stats */} -
-
-

{services.length}

-

Services

-
-
-

{runningServices}

-

Running

-
-
- 0} /> -
-
- - {formatRelative(project.updatedAt)} -
-
- - {/* Search / Command Palette */} - - - {/* Add Service Button */} - {!isDemoMode && ( - - )} -
+
+
+ 0} />
+ + + {formatRelative(project.updatedAt)} + + + {!isDemoMode && ( + + )}
- {/* Demo Mode Banner */} - {isDemoMode && ( -
-
-
- - Demo mode active — using sample data for preview + {/* Content */} + {canvasView ? ( +
+ navigate(serviceHref(serviceId))} + readOnly={isDemoMode} + /> + {isDemoMode && ( +
+ + + Demo data + +
+ )} +
+ ) : ( +
+ {isDemoMode && ( +
+
+
+ + Demo mode active - using sample data for preview +
-
- )} - - {/* Main Content */} -
-
- {/* Sidebar Navigation */} - - - {/* Content Area */} + )} +
- {activeView === 'canvas' && ( - navigate(serviceHref(serviceId))} - /> - )} - {activeView === 'observability' && (
{/* Health Summary */} @@ -965,6 +947,7 @@ export function ProjectWorkspacePage() {
+ )} {/* Create Service Dialog */} {!isDemoMode && ( diff --git a/app/frontend/src/features/workspace/storage.ts b/app/frontend/src/features/workspace/storage.ts index 1a8c74c..e221521 100644 --- a/app/frontend/src/features/workspace/storage.ts +++ b/app/frontend/src/features/workspace/storage.ts @@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata)); } -export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata { +export function loadCanvasMetadata( + projectId: string, + services: ServiceEntity[], + layout?: Map, +): ProjectCanvasMetadata { const key = canvasStorageKey(projectId); - const fallback = createDefaultCanvasMetadata(services); + const fallback = createDefaultCanvasMetadata(services, layout); const serviceIds = new Set(services.map((service) => service.id)); 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 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); if (existing) { normalizedNodes.push({ @@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]) continue; } - const col = index % 3; - const row = Math.floor(index / 3); + const parked = layout?.get(service.id); normalizedNodes.push({ serviceId: service.id, - position: { - x: 70 + col * 260, - y: 80 + row * 170, - }, + position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 }, }); + if (!parked) { + parkedRow += 1; + } } const seenEdgeIds = new Set(); diff --git a/app/frontend/src/index.css b/app/frontend/src/index.css index e10257d..e0db988 100644 --- a/app/frontend/src/index.css +++ b/app/frontend/src/index.css @@ -634,13 +634,13 @@ textarea { .edge-premium .react-flow__edge-path { stroke-linecap: 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; } .edge-premium:hover .react-flow__edge-path { - filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5)); - stroke-width: 3px; + filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45)); + stroke-width: 2.6px; } /* ============================================ @@ -1685,3 +1685,86 @@ textarea { margin: 24px 0; } .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; + } +} diff --git a/docs/screenshots/canvas.png b/docs/screenshots/canvas.png index ab2e1d5..578ce1e 100644 Binary files a/docs/screenshots/canvas.png and b/docs/screenshots/canvas.png differ