diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index ef3444c..b73d21c 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -116,10 +116,12 @@ export default function App() { } /> {/* Public browsing — approved projects, templates, docs. */} + {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} + } /> + } /> + }> } /> - } /> - } /> } /> } /> } /> diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 66caa3f..b26f057 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -51,6 +51,8 @@ import { type FailoverPolicy, } from '@/lib/api-client'; import { demoDatabases } from '@/lib/demo-data'; +import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon'; +import { serviceAccent } from '@/features/workspace/canvas/service-visuals'; import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { useAuthSession } from '@/lib/use-auth-session'; import { formatRelative } from '@/lib/time'; @@ -1668,8 +1670,14 @@ export function DatabasesPage() { onClick={() => setExpandedId(expanded ? null : (db.id ?? null))} className="flex w-full items-center gap-3 p-4 text-left" > -
- +
+
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 43bd837..f13cf63 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -36,6 +36,8 @@ import { type CronJobEntity, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; +import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon'; +import { serviceAccent } from '@/features/workspace/canvas/service-visuals'; import { useAuthSession } from '@/lib/use-auth-session'; import { getCurrentUserProfile } from '@/lib/api-client'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; @@ -134,7 +136,10 @@ export function ServiceDetailPage() { }); const isAdmin = Boolean(profileQuery.data?.isAdmin); - const [activeSection, setActiveSection] = useState('metrics'); + const [activeSection, setActiveSection] = useState(() => { + const requested = searchParams.get('section'); + return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics'; + }); const [logTail, setLogTail] = useState('100'); const [varDrafts, setVarDrafts] = useState(null); const [bulkOpen, setBulkOpen] = useState(false); @@ -382,7 +387,7 @@ export function ServiceDetailPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); - navigate(`/projects/${projectId}`); + navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`); }, }); @@ -590,18 +595,17 @@ export function ServiceDetailPage() { {/* Project Header - self.html exact match */}
-
- - - +
diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index 53f0aef..508e92c 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,28 +32,35 @@ 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 { ServiceInspector, type InspectorGroup } from './inspector'; +import { serviceAccent } from './service-visuals'; import { Plus, Layers, Maximize2, - RotateCcw, - Box, - Link2, + Minus, Play, Square, Rocket, Trash2, ArrowUpRight, Globe, + Wand2, + Pencil, + Ungroup, } from 'lucide-react'; + type CanvasProps = { projectId: string; services: ServiceEntity[]; variablesByService: Record; onAddService: () => void; onOpenService: (serviceId: string) => void; + onOpenSection?: (serviceId: string, section: string) => void; + readOnly?: boolean; }; type ServiceCanvasNode = Node; @@ -58,10 +68,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, @@ -69,17 +78,25 @@ const nodeTypes: NodeTypes = { }; type ContextMenuState = { - serviceId: string; + kind: 'service' | 'group' | 'pane'; + targetId?: string; x: number; y: number; + flowX?: number; + flowY?: 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 +128,7 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], draggable: true, selectable: true, style: { - width: SERVICE_NODE_WIDTH, + width: NODE_WIDTH, }, }; }); @@ -122,6 +139,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 +147,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,25 +157,140 @@ 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', }; }); } +const GROUP_PAD_X = 28; +const GROUP_PAD_TOP = 56; +const GROUP_PAD_BOTTOM = 24; +const GROUP_ROW_GAP = 16; +const GRID_STEP = 26; +const NODE_GAP = 14; + +type Rect = { x: number; y: number; w: number; h: number }; + +function rectsOverlap(a: Rect, b: Rect): boolean { + return ( + a.x < b.x + b.w + NODE_GAP && + a.x + a.w + NODE_GAP > b.x && + a.y < b.y + b.h + NODE_GAP && + a.y + a.h + NODE_GAP > b.y + ); +} + +// Nearest grid-aligned position where a node fits without touching any +// occupied rect. Spiral-searches outward on the canvas lattice; falls back to +// the given position when the neighbourhood is fully boxed in. +function resolveFreeSpot( + desired: { x: number; y: number }, + occupied: Rect[], + fallback: { x: number; y: number }, + bounds?: Rect, +): { x: number; y: number } { + const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP; + const base = { x: snap(desired.x), y: snap(desired.y) }; + const fits = (x: number, y: number) => { + if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) { + return false; + } + return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect)); + }; + + if (fits(base.x, base.y)) { + return base; + } + + for (let ring = 1; ring <= 12; ring++) { + for (let dx = -ring; dx <= ring; dx++) { + for (let dy = -ring; dy <= ring; dy++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) { + continue; + } + const x = base.x + dx * GRID_STEP; + const y = base.y + dy * GRID_STEP; + if (fits(x, y)) { + return { x, y }; + } + } + } + } + + return fallback; +} + +// Where an incoming service lands inside a group frame. If the point already +// fits inside the padded area, honour it. Otherwise append beneath the lowest +// member and grow the frame just enough — keeps groups compact instead of +// ballooning the frame to wherever the node used to sit. +function groupSlot( + nodes: CanvasNode[], + groupId: string, + group: CanvasNode, + desiredX: number, + desiredY: number, + ignoreServiceId?: string, +): { x: number; y: number; width: number; height: number } { + const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH; + const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT; + + const members = nodes.filter( + (node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId, + ); + const memberRects: Rect[] = members.map((node) => ({ + x: node.position.x, + y: node.position.y, + w: NODE_WIDTH, + h: NODE_HEIGHT, + })); + const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP; + const snappedX = snap(desiredX); + const snappedY = snap(desiredY); + const snappedFits = + snappedX >= GROUP_PAD_X && + snappedY >= GROUP_PAD_TOP && + snappedX + NODE_WIDTH <= width - GROUP_PAD_X && + snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM; + const collides = memberRects.some((rect) => + rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect), + ); + + if (snappedFits && !collides) { + return { x: snappedX, y: snappedY, width, height }; + } + + let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP; + for (const node of nodes) { + if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) { + bottom = Math.max(bottom, node.position.y + NODE_HEIGHT); + } + } + + const x = GROUP_PAD_X; + const y = bottom + GROUP_ROW_GAP; + return { + x, + y, + width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X), + height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM), + }; +} + function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata { const groups: CanvasGroup[] = []; const layouts: CanvasNodeLayout[] = []; @@ -190,7 +326,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, onOpenSection, readOnly }: CanvasProps) { const wrapperRef = useRef(null); const persistTimeout = useRef(null); const hydratedRef = useRef(false); @@ -203,7 +390,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 +413,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 +475,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on onSuccess: () => { toast.showToast('Service deleted', 'success'); invalidateServices(); + setSelectedServiceId(null); }, onError: actionError, }); @@ -286,10 +486,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 +520,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 +529,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 +570,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) { @@ -370,6 +629,27 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on return null; }, [getInternalNode, nodes]); + const dragStartPositions = useRef>(new Map()); + + const onNodeDragStart = useCallback( + (_event: MouseEvent, node: CanvasNode) => { + const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; + dragStartPositions.current.set(node.id, { ...abs }); + }, + [getInternalNode], + ); + + const absoluteServiceRects = useCallback( + (excludeId: string): Rect[] => + nodes + .filter((node) => node.type === 'serviceNode' && node.id !== excludeId) + .map((node) => { + const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position; + return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; + }), + [getInternalNode, nodes], + ); + const onNodeDragStop = useCallback( (_event: MouseEvent, movedNode: CanvasNode) => { if (movedNode.type !== 'serviceNode') { @@ -377,39 +657,90 @@ 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 fallback = dragStartPositions.current.get(movedNode.id) ?? basePosition; + const centerX = basePosition.x + NODE_WIDTH / 2; + const centerY = basePosition.y + NODE_HEIGHT / 2; const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId); if (targetGroup) { const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position; - 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)); + setNodes((current) => { + const slot = groupSlot( + current, + targetGroup.id, + targetGroup, + basePosition.x - groupBase.x, + basePosition.y - groupBase.y, + movedNode.id, + ); - setNodes((current) => - current.map((node) => { - if (node.id !== movedNode.id || node.type !== 'serviceNode') { - return node; + return current.map((node) => { + if (node.id === targetGroup.id && node.type === 'groupNode') { + return { ...node, style: { ...node.style, width: slot.width, height: slot.height } }; } + if (node.id === movedNode.id && node.type === 'serviceNode') { + return { + ...node, + parentId: targetGroup.id, + extent: 'parent' as const, + position: { x: slot.x, y: slot.y }, + }; + } + return node; + }); + }); - return { - ...node, - parentId: targetGroup.id, - extent: 'parent', - position: { x: relativeX, y: relativeY }, + return; + } + + // Member staying inside its own frame — keep membership, but resolve + // against siblings so nodes can't stack on each other. + if (movedNode.parentId) { + const parent = nodes.find((entry) => entry.id === movedNode.parentId && entry.type === 'groupNode'); + if (parent) { + const parentAbs = getInternalNode(parent.id)?.internals.positionAbsolute ?? parent.position; + const parentW = typeof parent.style?.width === 'number' ? parent.style.width : GROUP_DEFAULT_WIDTH; + const parentH = typeof parent.style?.height === 'number' ? parent.style.height : GROUP_DEFAULT_HEIGHT; + const inside = + centerX >= parentAbs.x && + centerX <= parentAbs.x + parentW && + centerY >= parentAbs.y && + centerY <= parentAbs.y + parentH; + if (inside) { + const siblingRects: Rect[] = nodes + .filter( + (entry) => + entry.type === 'serviceNode' && entry.parentId === parent.id && entry.id !== movedNode.id, + ) + .map((entry) => ({ x: entry.position.x, y: entry.position.y, w: NODE_WIDTH, h: NODE_HEIGHT })); + const relDesired = { x: basePosition.x - parentAbs.x, y: basePosition.y - parentAbs.y }; + const relFallback = { + x: Math.max(GROUP_PAD_X, Math.min(parentW - GROUP_PAD_X - NODE_WIDTH, fallback.x - parentAbs.x)), + y: Math.max(GROUP_PAD_TOP, Math.min(parentH - GROUP_PAD_BOTTOM - NODE_HEIGHT, fallback.y - parentAbs.y)), }; - }), - ); - - return; + const bounds: Rect = { + x: GROUP_PAD_X, + y: GROUP_PAD_TOP, + w: Math.max(NODE_WIDTH, parentW - GROUP_PAD_X * 2), + h: Math.max(NODE_HEIGHT, parentH - GROUP_PAD_TOP - GROUP_PAD_BOTTOM), + }; + const spot = resolveFreeSpot(relDesired, siblingRects, relFallback, bounds); + setNodes((current) => + current.map((entry) => + entry.id === movedNode.id && entry.type === 'serviceNode' + ? { ...entry, position: spot } + : entry, + ), + ); + return; + } + } } - if (!movedNode.parentId) { - return; - } + // Free placement — snapped to the canvas grid and pushed off any node it + // would overlap; reverts to the pre-drag spot if fully boxed in. + const spot = resolveFreeSpot(basePosition, absoluteServiceRects(movedNode.id), fallback); setNodes((current) => current.map((node) => { @@ -421,47 +752,190 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on ...node, parentId: undefined, extent: undefined, - position: { - x: basePosition.x, - y: basePosition.y, - }, + position: spot, }; }), ); }, - [getGroupUnderPoint, getInternalNode, setNodes], + [absoluteServiceRects, getGroupUnderPoint, getInternalNode, nodes, setNodes], ); - const addGroup = useCallback(() => { - const bounds = wrapperRef.current?.getBoundingClientRect(); - const center = bounds - ? screenToFlowPosition({ - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - }) - : { x: 180, y: 140 }; + const addGroup = useCallback( + (at?: { x: number; y: number }) => { + const bounds = wrapperRef.current?.getBoundingClientRect(); + const center = + at ?? + (bounds + ? screenToFlowPosition({ + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }) + : { x: 180, y: 140 }); - const id = `group-${Date.now()}`; + const id = `group-${Date.now()}`; - setNodes((current) => [ - ...current, - { - id, - type: 'groupNode', - position: { - x: center.x - GROUP_DEFAULT_WIDTH / 2, - y: center.y - GROUP_DEFAULT_HEIGHT / 2, - }, - data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` }, - draggable: true, - selectable: true, - style: { - width: GROUP_DEFAULT_WIDTH, - height: GROUP_DEFAULT_HEIGHT, - }, - } satisfies GroupCanvasNode, - ]); - }, [screenToFlowPosition, setNodes]); + setNodes((current) => [ + { + id, + type: 'groupNode', + position: { + x: center.x - GROUP_DEFAULT_WIDTH / 2, + y: center.y - GROUP_DEFAULT_HEIGHT / 2, + }, + data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup }, + draggable: true, + selectable: true, + style: { + width: GROUP_DEFAULT_WIDTH, + height: GROUP_DEFAULT_HEIGHT, + }, + } satisfies GroupCanvasNode, + // Groups must precede their children in the array — keep it first. + ...current, + ]); + + return id; + }, + [screenToFlowPosition, setNodes, renameGroup], + ); + + // Move a service node in/out of a group — mirrors the drag-drop math so + // assignment via the inspector lands identically to dropping the node on a + // group frame. + const assignToGroup = useCallback( + (serviceId: string, groupId: string | null) => { + const node = nodes.find((entry) => entry.id === serviceId && entry.type === 'serviceNode'); + if (!node || node.parentId === (groupId ?? undefined)) { + return; + } + const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position; + + if (groupId === null) { + setNodes((current) => { + const occupied: Rect[] = current + .filter((entry) => entry.type === 'serviceNode' && entry.id !== serviceId) + .map((entry) => { + const entryAbs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; + return { x: entryAbs.x, y: entryAbs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; + }); + const spot = resolveFreeSpot(abs, occupied, abs); + return current.map((entry) => + entry.id === serviceId && entry.type === 'serviceNode' + ? { ...entry, parentId: undefined, extent: undefined, position: spot } + : entry, + ); + }); + return; + } + + setNodes((current) => { + const group = current.find((entry) => entry.id === groupId && entry.type === 'groupNode'); + if (!group) { + return current; + } + const groupAbs = getInternalNode(groupId)?.internals.positionAbsolute ?? group.position; + const slot = groupSlot(current, groupId, group, abs.x - groupAbs.x, abs.y - groupAbs.y); + + return current.map((entry) => { + if (entry.id === groupId && entry.type === 'groupNode') { + return { ...entry, style: { ...entry.style, width: slot.width, height: slot.height } }; + } + if (entry.id === serviceId && entry.type === 'serviceNode') { + return { ...entry, parentId: groupId, extent: 'parent' as const, position: { x: slot.x, y: slot.y } }; + } + return entry; + }); + }); + }, + [getInternalNode, nodes, setNodes], + ); + + // Create a group frame wrapped around the service and adopt the node into it. + const newGroupForService = useCallback( + (serviceId: string) => { + const node = nodes.find((entry) => entry.id === serviceId && entry.type === 'serviceNode'); + if (!node) { + return; + } + const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position; + const groupId = `group-${Date.now()}`; + + setNodes((current) => [ + // Parent precedes the child in the array. + { + id: groupId, + type: 'groupNode', + position: { x: abs.x - GROUP_PAD_X, y: abs.y - GROUP_PAD_TOP }, + data: { + title: `Group ${current.filter((entry) => entry.type === 'groupNode').length + 1}`, + onRename: renameGroup, + }, + draggable: true, + selectable: true, + style: { + width: NODE_WIDTH + GROUP_PAD_X * 2, + height: NODE_HEIGHT + GROUP_PAD_TOP + GROUP_PAD_BOTTOM, + }, + } satisfies GroupCanvasNode, + ...current.map((entry) => + entry.id === serviceId && entry.type === 'serviceNode' + ? { + ...entry, + parentId: groupId, + extent: 'parent' as const, + position: { x: GROUP_PAD_X, y: GROUP_PAD_TOP }, + } + : entry, + ), + ]); + }, + [getInternalNode, nodes, renameGroup, setNodes], + ); + + const deleteGroup = useCallback( + (groupId: string) => { + setNodes((current) => { + const memberIds = new Set( + current + .filter((entry) => entry.type === 'serviceNode' && entry.parentId === groupId) + .map((entry) => entry.id), + ); + // Freed members must not land on free nodes — resolve each in turn and + // treat already-placed members as occupied for the next. + const occupied: Rect[] = current + .filter((entry) => entry.type === 'serviceNode' && !memberIds.has(entry.id)) + .map((entry) => { + const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; + return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT }; + }); + const unparented = current.map((entry) => { + if (entry.type !== 'serviceNode' || entry.parentId !== groupId) { + return entry; + } + const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position; + const spot = resolveFreeSpot(abs, occupied, abs); + occupied.push({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT }); + return { ...entry, parentId: undefined, extent: undefined, position: spot }; + }); + return unparented.filter((entry) => entry.id !== groupId); + }); + setContextMenu(null); + }, + [getInternalNode, setNodes], + ); + + const requestGroupRename = useCallback( + (groupId: string) => { + setNodes((current) => + current.map((entry) => + entry.id === groupId && entry.type === 'groupNode' + ? { ...entry, data: { ...entry.data, renameNonce: (entry.data.renameNonce ?? 0) + 1 } } + : entry, + ), + ); + }, + [setNodes], + ); // Env filter hides nodes via React Flow's `hidden` flag — positions and // persisted layout stay intact for services outside the current filter. @@ -473,301 +947,436 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on [nodes, visibleIds], ); + const menuPosition = useCallback((clientX: number, clientY: number, menuW = 190, menuH = 240) => { + const bounds = wrapperRef.current?.getBoundingClientRect(); + const x = Math.min(clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW)); + const y = Math.min(clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH)); + return { x, y }; + }, []); + const onNodeContextMenu = useCallback( (event: MouseEvent, node: CanvasNode) => { - if (node.type !== 'serviceNode') { - return; - } event.preventDefault(); - const bounds = wrapperRef.current?.getBoundingClientRect(); - setSelectedServiceId(node.id); - const menuW = 190; - const menuH = 230; - const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW)); - const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH)); - setContextMenu({ serviceId: node.id, x, y }); + if (node.type === 'serviceNode') { + setSelectedServiceId(node.id); + setContextMenu({ kind: 'service', targetId: node.id, ...menuPosition(event.clientX, event.clientY) }); + } else if (node.type === 'groupNode') { + setContextMenu({ kind: 'group', targetId: node.id, ...menuPosition(event.clientX, event.clientY, 190, 140) }); + } + }, + [menuPosition], + ); + + const onPaneContextMenu = useCallback( + (event: MouseEvent | globalThis.MouseEvent) => { + event.preventDefault(); + const flow = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + setContextMenu({ kind: 'pane', ...menuPosition(event.clientX, event.clientY, 200, 170), flowX: flow.x, flowY: flow.y }); + }, + [menuPosition, screenToFlowPosition], + ); + + const contextService = + contextMenu?.kind === 'service' && contextMenu.targetId + ? services.find((service) => service.id === contextMenu.targetId) ?? null + : null; + const contextGroup = + contextMenu?.kind === 'group' && contextMenu.targetId + ? nodes.find( + (node): node is GroupCanvasNode => node.id === contextMenu.targetId && node.type === 'groupNode', + ) ?? null + : null; + const inspectorGroups: InspectorGroup[] = useMemo( + () => + nodes + .filter((node): node is GroupCanvasNode => node.type === 'groupNode') + .map((node) => ({ id: node.id, title: node.data.title })), + [nodes], + ); + const selectedNode = selectedServiceId ? nodes.find((node) => node.id === selectedServiceId) : undefined; + + const selectedService = services.find((service) => service.id === selectedServiceId) ?? null; + const selectedConnections = useMemo(() => { + if (!selectedService) { + return []; + } + const nameOf = (id: string) => services.find((service) => service.id === id)?.name ?? id; + return inferredLinks + .filter( + (link) => + link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id, + ) + .map((link) => ({ + id: link.edge.id, + outbound: link.edge.sourceServiceId === selectedService.id, + peer: link.edge.sourceServiceId === selectedService.id ? nameOf(link.edge.targetServiceId) : nameOf(link.edge.sourceServiceId), + peerId: link.edge.sourceServiceId === selectedService.id ? link.edge.targetServiceId : link.edge.sourceServiceId, + reasons: link.reasons, + })); + }, [inferredLinks, selectedService, services]); + + const minimapNodeColor = useCallback( + (node: CanvasNode) => { + if (node.type === 'groupNode') { + return 'rgba(255,255,255,0.08)'; + } + return serviceAccent((node.data as ServiceNodeData).service); }, [], ); - 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( - (link) => - link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id, - ).length - : 0; - 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} + onPaneContextMenu={onPaneContextMenu} + onNodeDragStart={onNodeDragStart} + onNodeDragStop={onNodeDragStop} + snapToGrid + snapGrid={[GRID_STEP, GRID_STEP]} + onMoveEnd={() => setViewportTick((value) => value + 1)} + panOnDrag + zoomOnScroll + zoomOnDoubleClick={false} + nodesConnectable={false} + nodeDragThreshold={3} + onlyRenderVisibleElements + minZoom={0.2} + maxZoom={2.2} + deleteKeyCode={null} + selectionKeyCode="Shift" + proOptions={{ hideAttribution: true }} + > + + + + + {/* Floating: env filter + group (top-left) */} +
+ {environments.length > 1 && ( +
+ {['all', ...environments].map((env) => ( + + ))} +
+ )} -
- - - {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 && ( + deployMutation.mutate(selectedService.id), + start: () => startMutation.mutate(selectedService.id), + restart: () => restartMutation.mutate(selectedService.id), + stop: () => stopMutation.mutate(selectedService.id), + remove: () => { + if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) { + deleteMutation.mutate(selectedService.id); + } + }, + pending: actionPending, + deployPending: deployMutation.isPending, + restartPending: restartMutation.isPending, + }} + onClose={() => setSelectedServiceId(null)} + onOpenService={onOpenService} + onOpenSection={(serviceId, section) => + onOpenSection ? onOpenSection(serviceId, section) : onOpenService(serviceId) + } + onSelectPeer={setSelectedServiceId} + onAssignGroup={assignToGroup} + onNewGroupFor={newGroupForService} + /> + )} + + {/* Empty state */} + {services.length === 0 && ( +
+
+
+ +
+

Deploy your first service

+

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

+ +
+
+ )} + + {/* Context menus — service node, group frame, or empty pane */} + {contextMenu && ( +
+ {contextMenu.kind === 'pane' && ( + <> -
-
- )} - - {/* Node context menu */} - {contextMenu && contextService && ( -
-
-

{contextService.name}

-

{contextService.status}

-
- - {(contextService.domain || contextService.publicUrl) && ( - { + setContextMenu(null); + onAddService(); + }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors" > - - Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} - - )} - + +
+ + +
+ + )} + + {contextMenu.kind === 'group' && contextGroup && ( + <> +
+

{contextGroup.data.title}

+

Group

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

{contextService.name}

+

{contextService.status}

+
+ + {(contextService.domain || contextService.publicUrl) && ( + - - Deploy - - {contextService.status !== 'running' && ( + + 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/ServiceIcon.tsx b/app/frontend/src/features/workspace/canvas/ServiceIcon.tsx new file mode 100644 index 0000000..17d9fc0 --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/ServiceIcon.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals'; + +export function ServiceIcon({ + service, + size = 16, +}: { + service: ServiceIconInput; + size?: number; +}) { + const [failed, setFailed] = useState(false); + const brand = serviceIcon(service); + if (brand && !failed) { + return ( + setFailed(true)} + /> + ); + } + return serviceTypeIcon(service.type ?? 'service', size); +} 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..3c710c8 --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/auto-layout.ts @@ -0,0 +1,112 @@ +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; +// Multiples of the 26px canvas grid so laid-out nodes land on the lattice. +export const COL_GAP = 338; +export const ROW_GAP = 182; + +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, + ); + + const gridOffset = (value: number) => Math.round(value / 26) * 26; + + for (const [col, list] of columns) { + const offsetY = gridOffset(((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 = gridOffset(((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/inspector.tsx b/app/frontend/src/features/workspace/canvas/inspector.tsx new file mode 100644 index 0000000..b3c748c --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/inspector.tsx @@ -0,0 +1,415 @@ +import type { ServiceEntity } from '@/lib/api-client'; +import { serviceStatusClass } from '@/lib/api-client'; +import type { ServiceVariable } from '../auto-connections'; +import { ServiceIcon } from './ServiceIcon'; +import { serviceAccent } from './service-visuals'; +import { formatRelative } from '@/lib/time'; +import { + ArrowUpRight, + Copy, + Globe, + KeyRound, + Layers, + Link2, + Loader2, + Play, + Rocket, + Square, + Trash2, + X, +} from 'lucide-react'; +import { useState } from 'react'; + +export type InspectorGroup = { id: string; title: string }; +export type InspectorConnection = { + id: string; + outbound: boolean; + peer: string; + peerId: string; + reasons: string[]; +}; + +export type InspectorActions = { + deploy: () => void; + start: () => void; + restart: () => void; + stop: () => void; + remove: () => void; + pending: boolean; + deployPending: boolean; + restartPending: boolean; +}; + +function statusLabel(status: string): string { + switch (status) { + case 'running': + return 'Online'; + case 'deployed': + return 'Deployed'; + case 'building': + return 'Building'; + case 'deploying': + return 'Deploying'; + case 'pending': + return 'Pending'; + case 'failed': + return 'Failed'; + case 'stopped': + return 'Stopped'; + case 'rolling_back': + return 'Rolling back'; + default: + return status; + } +} + +function statusColor(status: string): string { + switch (status) { + case 'running': + case 'deployed': + return 'var(--success)'; + case 'failed': + return 'var(--error)'; + case 'building': + case 'deploying': + case 'pending': + case 'rolling_back': + return 'var(--warning)'; + default: + return 'var(--text-tertiary)'; + } +} + +function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} + +function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +export function ServiceInspector({ + service, + variables, + connections, + groups, + groupId, + readOnly, + actions, + onClose, + onOpenService, + onOpenSection, + onSelectPeer, + onAssignGroup, + onNewGroupFor, +}: { + service: ServiceEntity; + variables: ServiceVariable[]; + connections: InspectorConnection[]; + groups: InspectorGroup[]; + groupId: string | undefined; + readOnly?: boolean; + actions: InspectorActions; + onClose: () => void; + onOpenService: (serviceId: string) => void; + onOpenSection: (serviceId: string, section: string) => void; + onSelectPeer: (serviceId: string) => void; + onAssignGroup: (serviceId: string, groupId: string | null) => void; + onNewGroupFor: (serviceId: string) => void; +}) { + const accent = serviceAccent(service); + const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; + const [copied, setCopied] = useState(false); + + const copyAddress = () => { + const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name); + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }); + }; + + const configRows: [string, string | undefined][] = [ + ['Image', service.image], + ['Command', service.command], + ['Port', service.port ? String(service.port) : undefined], + ['Domain', service.domain], + ['Git repo', service.gitRepo], + ['Branch', service.gitBranch], + ['Build path', service.buildPath], + ['CPU', service.cpu], + ['Memory', service.memory], + ['Restart', service.restartPolicy], + ['Healthcheck', service.healthcheckPath], + ]; + const visibleConfig = configRows.filter(([, value]) => value); + + return ( + + ); +} diff --git a/app/frontend/src/features/workspace/canvas/nodes.tsx b/app/frontend/src/features/workspace/canvas/nodes.tsx index 942babf..f6b85da 100644 --- a/app/frontend/src/features/workspace/canvas/nodes.tsx +++ b/app/frontend/src/features/workspace/canvas/nodes.tsx @@ -1,8 +1,10 @@ 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 { serviceIcon, serviceAccent } from './service-visuals'; +import { ServiceIcon } from './ServiceIcon'; export type ServiceNodeData = { service: ServiceEntity; @@ -12,37 +14,13 @@ export type ServiceNodeData = { export type GroupNodeData = { title: string; + onRename?: (groupId: string, title: string) => void; + renameNonce?: number; }; 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 +50,30 @@ 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 brand = serviceIcon(service); + const typeColor = brand?.color ?? serviceAccent(service); 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 +86,26 @@ export function ServiceNode({ data }: NodeProps) { style={{ background: stateColor }} /> -
-
+
+
- {renderServiceTypeIcon(service.type, 18)} + {isTransient ? ( + + ) : ( + + )}
-
-

{service.name}

-

+

+

+ {service.name} +

+

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

@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps) { )}
-
+
-
+
- + {statusLabel(service.status)}
{service.image && ( - + {service.image} )} @@ -181,41 +166,78 @@ 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); + + // Context-menu rename requests bump the nonce; enter edit mode when it + // changes. Render-phase state adjustment — the React-sanctioned pattern. + const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0); + const incomingNonce = data.renameNonce ?? 0; + if (incomingNonce > seenRenameNonce) { + setSeenRenameNonce(incomingNonce); + setDraft(data.title); + setEditing(true); + } + + 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'; + } +} + +export type ServiceBrandIcon = { + slug: string; + color: string; +}; + +const TECH_ICONS: (ServiceBrandIcon & { patterns: RegExp[] })[] = [ + { slug: 'postgresql', color: '#7ab8ff', patterns: [/postgres/i, /postgis/i] }, + { slug: 'redis', color: '#ff6b6b', patterns: [/redis/i, /dragonfly/i, /valkey/i, /keydb/i] }, + { slug: 'mysql', color: '#5ba4e5', patterns: [/mysql/i] }, + { slug: 'mariadb', color: '#4ea5ff', patterns: [/mariadb/i] }, + { slug: 'mongodb', color: '#47a248', patterns: [/mongo/i] }, + { slug: 'clickhouse', color: '#ffcc01', patterns: [/clickhouse/i] }, + { slug: 'couchdb', color: '#e42528', patterns: [/couchdb/i] }, + { slug: 'sqlite', color: '#69b7e4', patterns: [/sqlite/i] }, + { slug: 'influxdb', color: '#22adf6', patterns: [/influx/i] }, + { slug: 'elasticsearch', color: '#fec514', patterns: [/elastic/i, /opensearch/i] }, + { slug: 'meilisearch', color: '#ff5caa', patterns: [/meili/i] }, + { slug: 'typesense', color: '#d90368', patterns: [/typesense/i] }, + { slug: 'qdrant', color: '#dc244c', patterns: [/qdrant/i] }, + { slug: 'weaviate', color: '#01c9a7', patterns: [/weaviate/i] }, + { slug: 'minio', color: '#c72e49', patterns: [/minio/i] }, + { slug: 'rabbitmq', color: '#ff6600', patterns: [/rabbitmq/i, /rabbit/i] }, + { slug: 'apachekafka', color: '#d6dae4', patterns: [/kafka/i] }, + { slug: 'natsdotio', color: '#8be1d3', patterns: [/\bnats/i] }, + { slug: 'nginx', color: '#009639', patterns: [/nginx/i] }, + { slug: 'caddy', color: '#1f88c0', patterns: [/caddy/i] }, + { slug: 'traefikproxy', color: '#24a1c1', patterns: [/traefik/i] }, + { slug: 'nodedotjs', color: '#5fa04e', patterns: [/node(?=[:\-\d]|$)/i, /nodejs/i] }, + { slug: 'go', color: '#00add8', patterns: [/golang/i, /(?:^|[^a-z])go(?=[:\-\d]|$)/i] }, + { slug: 'python', color: '#6fa8dc', patterns: [/python/i, /\bpy(?:thon)?(?=[:\-\d]|$)/i] }, + { slug: 'rust', color: '#f74c00', patterns: [/rust/i] }, + { slug: 'php', color: '#8892bf', patterns: [/php/i] }, + { slug: 'ruby', color: '#cc342d', patterns: [/ruby/i, /rails/i] }, + { slug: 'openjdk', color: '#e6a23c', patterns: [/java/i, /openjdk/i, /jdk/i, /jre/i] }, + { slug: 'dotnet', color: '#6f4bd8', patterns: [/dotnet/i, /aspnet/i] }, + { slug: 'deno', color: '#70ffaf', patterns: [/\bdeno/i] }, + { slug: 'bun', color: '#f9f1e1', patterns: [/bun(?=[:\-\d]|$)/i] }, + { slug: 'react', color: '#61dafb', patterns: [/react/i] }, + { slug: 'vuedotjs', color: '#4fc08d', patterns: [/vue/i] }, + { slug: 'angular', color: '#dd0031', patterns: [/angular/i] }, + { slug: 'svelte', color: '#ff3e00', patterns: [/svelte/i] }, + { slug: 'nextdotjs', color: '#e6edf3', patterns: [/nextjs/i, /next\.js/i] }, + { slug: 'nuxtdotjs', color: '#00dc82', patterns: [/nuxt/i] }, + { slug: 'astro', color: '#ff5d01', patterns: [/astro/i] }, + { slug: 'prometheus', color: '#e6522c', patterns: [/prometheus/i] }, + { slug: 'grafana', color: '#f46800', patterns: [/grafana/i] }, + { slug: 'uptimekuma', color: '#5cdd8b', patterns: [/uptime.?kuma/i, /\bkuma/i] }, + { slug: 'n8n', color: '#ea4b71', patterns: [/n8n/i] }, + { slug: 'wordpress', color: '#4b9cd3', patterns: [/wordpress/i, /\bwp-/i] }, + { slug: 'ghost', color: '#a8b8c4', patterns: [/ghost/i] }, + { slug: 'nextcloud', color: '#0082c9', patterns: [/nextcloud/i] }, + { slug: 'immich', color: '#7c9ef5', patterns: [/immich/i] }, + { slug: 'supabase', color: '#3fcf8e', patterns: [/supabase/i] }, + { slug: 'pocketbase', color: '#b8dbe4', patterns: [/pocketbase/i] }, + { slug: 'keycloak', color: '#d6dae4', patterns: [/keycloak/i] }, + { slug: 'authentik', color: '#fd4b2d', patterns: [/authentik/i] }, + { slug: 'portainer', color: '#13bef9', patterns: [/portainer/i] }, + { slug: 'docker', color: '#2496ed', patterns: [/docker/i, /dind/i] }, + { slug: 'kubernetes', color: '#326ce5', patterns: [/kubernetes/i, /\bk8s/i] }, +]; + +export type ServiceIconInput = { image?: string; name?: string; type?: string }; + +export function serviceIcon(service: ServiceIconInput): ServiceBrandIcon | null { + const text = `${service.image ?? ''} ${service.name ?? ''}`.toLowerCase(); + for (const icon of TECH_ICONS) { + if (icon.patterns.some((pattern) => pattern.test(text))) { + return icon; + } + } + return null; +} + +export function serviceIconUrl(icon: ServiceBrandIcon): string { + return `https://cdn.simpleicons.org/${icon.slug}/${icon.color.replace('#', '')}`; +} + +export function serviceAccent(service: ServiceIconInput): string { + return serviceIcon(service)?.color ?? serviceTypeColor(service.type ?? 'service'); +} 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..9f0dace 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -620,6 +620,8 @@ export function ProjectWorkspacePage() { isDemoMode ? `/projects/${projectId}/services/${serviceId}?demo=1` : `/projects/${projectId}/services/${serviceId}`; + const serviceSectionHref = (serviceId: string, section: string) => + `${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`; if (!isDemoMode && projectQuery.isLoading) { return ( @@ -644,7 +646,7 @@ export function ProjectWorkspacePage() { This project may have been deleted or you don't have access.

+
+

{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))} + onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))} + 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 +952,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/app/frontend/src/lib/demo-data.ts b/app/frontend/src/lib/demo-data.ts index c2f6fd0..d688a50 100644 --- a/app/frontend/src/lib/demo-data.ts +++ b/app/frontend/src/lib/demo-data.ts @@ -10,9 +10,9 @@ export const demoProjects: ProjectEntity[] = [ createdAt: '2026-03-12T09:30:00Z', updatedAt: '2026-03-31T09:10:00Z', stats: { - service_count: 4, + service_count: 5, deployment_count: 27, - running_services: 3, + running_services: 4, last_deployment: '2026-03-31T08:42:00Z', }, }, @@ -55,9 +55,11 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/web:2026.03.31', + image: 'ghcr.io/containr/web-react:v42', command: 'npm run serve', gitBranch: 'main', + port: 3000, + domain: 'web.containr.local', createdAt: '2026-03-12T10:00:00Z', updatedAt: '2026-03-31T08:42:00Z', }, @@ -68,9 +70,11 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/api:2026.03.31', + image: 'ghcr.io/containr/api-go:v2.4.1', command: './server', gitBranch: 'main', + port: 8080, + domain: 'api.containr.local', createdAt: '2026-03-12T10:02:00Z', updatedAt: '2026-03-31T08:43:00Z', }, @@ -81,9 +85,10 @@ export const demoServicesByProject: Record = { type: 'worker', status: 'building', environment: 'production', - image: 'ghcr.io/containr/worker:2026.03.31', + image: 'ghcr.io/containr/worker:edge', command: './worker', gitBranch: 'main', + port: 9100, createdAt: '2026-03-12T10:05:00Z', updatedAt: '2026-03-31T08:44:00Z', }, @@ -94,12 +99,27 @@ export const demoServicesByProject: Record = { type: 'database', status: 'running', environment: 'production', - image: 'postgres:15-alpine', + image: 'postgres:16', command: 'postgres', gitBranch: 'main', + port: 5432, createdAt: '2026-03-12T10:08:00Z', updatedAt: '2026-03-31T08:40:00Z', }, + { + id: 'demo-svc-redis', + projectId: 'demo-project-core', + name: 'Redis Cache', + type: 'database', + status: 'running', + environment: 'production', + image: 'dragonfly:1.25', + command: 'redis-server', + gitBranch: 'main', + port: 6379, + createdAt: '2026-03-12T10:12:00Z', + updatedAt: '2026-03-31T08:40:00Z', + }, ], 'demo-project-growth': [ { @@ -109,9 +129,11 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/marketing:latest', - command: 'npm run start', + image: 'nginx:1.27-alpine', + command: 'nginx -g daemon off;', gitBranch: 'main', + port: 8080, + domain: 'growth.containr.local', createdAt: '2026-03-03T09:00:00Z', updatedAt: '2026-03-30T12:10:00Z', }, @@ -122,9 +144,10 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/campaign-api:latest', + image: 'node:20-alpine', command: './api', gitBranch: 'main', + port: 3000, createdAt: '2026-03-03T09:04:00Z', updatedAt: '2026-03-30T12:08:00Z', }, @@ -135,9 +158,10 @@ export const demoServicesByProject: Record = { type: 'database', status: 'running', environment: 'production', - image: 'redis:7-alpine', + image: 'dragonfly:1.25', command: 'redis-server', gitBranch: 'main', + port: 6379, createdAt: '2026-03-03T09:07:00Z', updatedAt: '2026-03-30T12:07:00Z', }, @@ -150,9 +174,10 @@ export const demoServicesByProject: Record = { type: 'web', status: 'running', environment: 'production', - image: 'ghcr.io/containr/inference-api:latest', + image: 'python:3.12-slim', command: './serve', gitBranch: 'main', + port: 8000, createdAt: '2026-02-18T08:00:00Z', updatedAt: '2026-03-29T18:00:00Z', }, @@ -163,7 +188,7 @@ export const demoServicesByProject: Record = { type: 'worker', status: 'failed', environment: 'production', - image: 'ghcr.io/containr/evaluator:latest', + image: 'python:3.12-slim', command: './run-jobs', gitBranch: 'main', createdAt: '2026-02-18T08:03:00Z', @@ -195,6 +220,7 @@ export const demoVariablesByProject: Record