mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(canvas): fullscreen Railway-style workspace canvas
Rebuild the project canvas as a full-viewport graph instead of a boxed 66vh panel. Slim workspace top bar now holds the view tabs, project meta and actions; the canvas fills all remaining space. - Floating glass controls: env filter + group pill (top-left), zoom out / live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap (bottom-right), hint chip, demo badge. - Inspector panel: selecting a node opens a right-side glass panel with status, image, address, inferred connections incl. variable reasons, and deploy/restart/stop/delete actions. Esc or pane click closes. - Auto-layout: dependency-depth layered layout (consumers left, datastores right) for first-visit placement and the Tidy action. Unpositioned services park right of existing bounds, not at (0,0). - Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type icons/colors incl. cron, spinner icon while deploying, hover lift. - Edges: lime bezier strokes, animated dashes while either endpoint is deploying, hover glow updated from stale purple to accent. - Groups: off-brand crimson replaced with accent tokens, double-click title to rename inline. - Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select on Shift-drag. Double-click zoom disabled, dead connection drags off. - Mobile: icon-only tabs, minimap/hint hidden under md. - Retake docs/screenshots/canvas.png + site copy (1600x900 demo). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
84b102a2c6
commit
e57f0470eb
9 files changed
+950
-456
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
||||
import {
|
||||
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<string, ServiceVariable[]>;
|
||||
onAddService: () => void;
|
||||
onOpenService: (serviceId: string) => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
|
||||
@@ -58,10 +66,9 @@ type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
|
||||
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<typeof inferAutoConnections>,
|
||||
positionOf: (serviceId: string) => { x: number; y: number } | undefined,
|
||||
statusOf: (serviceId: string) => string | undefined,
|
||||
): CanvasEdge[] {
|
||||
return links.map((link) => {
|
||||
const source = positionOf(link.edge.sourceServiceId);
|
||||
@@ -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 (
|
||||
<div className="canvas-pill flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut({ duration: 160 })}
|
||||
className="canvas-pill-btn"
|
||||
title="Zoom out (-)"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
|
||||
className="canvas-pill-btn mono w-12 text-[11px]"
|
||||
title="Reset zoom (0)"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn({ duration: 160 })}
|
||||
className="canvas-pill-btn"
|
||||
title="Zoom in (+)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-[var(--border-subtle)] mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fitView({ padding: 0.18, duration: 240 })}
|
||||
className="canvas-pill-btn"
|
||||
title="Fit view (F)"
|
||||
>
|
||||
<Maximize2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTidy}
|
||||
className="canvas-pill-btn"
|
||||
title="Tidy layout"
|
||||
>
|
||||
<Wand2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, readOnly }: CanvasProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const persistTimeout = useRef<number | null>(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<CanvasNode, CanvasEdge>();
|
||||
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport, zoomIn, zoomOut } = useReactFlow<CanvasNode, CanvasEdge>();
|
||||
|
||||
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 (
|
||||
<div className="panel overflow-hidden">
|
||||
{/* Toolbar - Railway-inspired premium design */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddService}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Add Service
|
||||
</button>
|
||||
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
|
||||
<div ref={wrapperRef} className="relative h-full min-h-0 w-full bg-[var(--bg-void)]">
|
||||
<ReactFlow
|
||||
nodes={renderedNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onPaneClick={() => {
|
||||
setSelectedServiceId(null);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onMoveStart={() => setContextMenu(null)}
|
||||
onNodeClick={(_event, node) => {
|
||||
setContextMenu(null);
|
||||
if (node.type === 'serviceNode') {
|
||||
setSelectedServiceId(node.id);
|
||||
}
|
||||
}}
|
||||
onNodeDoubleClick={(_event, node) => {
|
||||
if (node.type === 'serviceNode') {
|
||||
onOpenService(node.id);
|
||||
}
|
||||
}}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
zoomOnDoubleClick={false}
|
||||
nodesConnectable={false}
|
||||
nodeDragThreshold={3}
|
||||
onlyRenderVisibleElements
|
||||
minZoom={0.2}
|
||||
maxZoom={2.2}
|
||||
deleteKeyCode={null}
|
||||
selectionKeyCode="Shift"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={minimapNodeColor}
|
||||
bgColor="rgba(14, 15, 18, 0.92)"
|
||||
maskColor="rgba(12, 13, 15, 0.72)"
|
||||
className="canvas-minimap hidden md:block"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Floating: env filter + group (top-left) */}
|
||||
<div className="absolute left-4 top-4 flex items-center gap-2">
|
||||
{environments.length > 1 && (
|
||||
<div className="canvas-pill flex items-center gap-0.5 p-1">
|
||||
{['all', ...environments].map((env) => (
|
||||
<button
|
||||
key={env}
|
||||
type="button"
|
||||
onClick={() => setEnvFilter(env)}
|
||||
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${
|
||||
envFilter === env
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{env}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addGroup}
|
||||
className="flex items-center gap-2 h-9 px-3 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] hover:border-[var(--border-default)] transition-all"
|
||||
className="canvas-pill canvas-pill-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Add group"
|
||||
>
|
||||
<Layers size={14} />
|
||||
<Layers size={13} />
|
||||
Group
|
||||
</button>
|
||||
|
||||
{environments.length > 1 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
|
||||
{['all', ...environments].map((env) => (
|
||||
<button
|
||||
key={env}
|
||||
type="button"
|
||||
onClick={() => setEnvFilter(env)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-all ${
|
||||
envFilter === env
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{env}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fitView({ padding: 0.2, duration: 240 })}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
|
||||
title="Fit View"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
|
||||
title="Reset View"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
||||
<Box size={12} />
|
||||
<span className="font-medium">{services.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-3 bg-[var(--border-subtle)]" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
||||
<Link2 size={12} />
|
||||
<span className="font-medium">{edges.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative">
|
||||
<ReactFlow
|
||||
nodes={renderedNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onPaneClick={() => {
|
||||
setSelectedServiceId(null);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onMoveStart={() => setContextMenu(null)}
|
||||
onNodeClick={(_event, node) => {
|
||||
setContextMenu(null);
|
||||
if (node.type === 'serviceNode') {
|
||||
setSelectedServiceId(node.id);
|
||||
}
|
||||
}}
|
||||
onNodeDoubleClick={(_event, node) => {
|
||||
if (node.type === 'serviceNode') {
|
||||
onOpenService(node.id);
|
||||
}
|
||||
}}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
minZoom={0.25}
|
||||
maxZoom={2.3}
|
||||
deleteKeyCode={null}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
{/* SVG Definitions for premium edge styling */}
|
||||
<svg style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
|
||||
<defs />
|
||||
</svg>
|
||||
<Background color="rgba(255,255,255,0.05)" gap={28} />
|
||||
</ReactFlow>
|
||||
{/* Floating: zoom controls (bottom-left) */}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<ZoomControls onTidy={tidyLayout} />
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{services.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||
<Rocket size={24} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
|
||||
Add a service from a git repo or Docker image — it runs on a private network with the rest of this
|
||||
project.
|
||||
{/* Floating: hint (bottom-center) — fades once a node is selected */}
|
||||
{!selectedService && services.length > 0 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 pointer-events-none hidden lg:block">
|
||||
<p className="canvas-pill px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)] whitespace-nowrap">
|
||||
Click to inspect · Double-click to open · Right-click for actions · F to fit
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inspector (right) */}
|
||||
{selectedService && (
|
||||
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[300px] panel-glass flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${serviceTypeColor(selectedService.type)}1c`, color: serviceTypeColor(selectedService.type) }}
|
||||
>
|
||||
{serviceTypeIcon(selectedService.type, 16)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{selectedService.name}</h3>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)]">
|
||||
{selectedService.type} · {selectedService.environment ?? 'production'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddService}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedServiceId(null)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{selectedService.image && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Image</p>
|
||||
<p className="mono text-[11px] text-[var(--text-secondary)] break-all">{selectedService.image}</p>
|
||||
</div>
|
||||
)}
|
||||
{(selectedService.domain || selectedService.publicUrl || selectedService.port) && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Address</p>
|
||||
{selectedService.domain || selectedService.publicUrl ? (
|
||||
<a
|
||||
href={selectedService.domain ? `https://${selectedService.domain}` : selectedService.publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
>
|
||||
{(selectedService.domain ?? selectedService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : null}
|
||||
{selectedService.port ? (
|
||||
<p className="mono text-[11px] text-[var(--text-secondary)] mt-0.5">
|
||||
{selectedService.name}:{selectedService.port}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Connections ({selectedConnections.length})
|
||||
</p>
|
||||
{selectedConnections.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
No inferred connections. Reference another service via <span className="mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{selectedConnections.map((conn) => (
|
||||
<button
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedServiceId(conn.peerId)}
|
||||
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
||||
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
|
||||
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
|
||||
</div>
|
||||
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
{conn.reasons.join(' · ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node context menu */}
|
||||
{contextMenu && contextService && (
|
||||
<div
|
||||
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenService(selectedService.id)}
|
||||
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Open service
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || readOnly}
|
||||
onClick={() => deployMutation.mutate(selectedService.id)}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{deployMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
||||
Deploy
|
||||
</button>
|
||||
{selectedService.status !== 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || readOnly}
|
||||
onClick={() => startMutation.mutate(selectedService.id)}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Play size={11} />
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || readOnly}
|
||||
onClick={() => restartMutation.mutate(selectedService.id)}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{restartMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || readOnly || selectedService.status !== 'running'}
|
||||
onClick={() => stopMutation.mutate(selectedService.id)}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Square size={11} />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || readOnly}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
onOpenService(contextService.id);
|
||||
if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) {
|
||||
deleteMutation.mutate(selectedService.id);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Open service
|
||||
<Trash2 size={11} />
|
||||
Delete service
|
||||
</button>
|
||||
{(contextService.domain || contextService.publicUrl) && (
|
||||
<a
|
||||
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<Globe size={13} />
|
||||
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{services.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||
<Rocket size={24} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
|
||||
Add a service from a git repo or Docker image - it runs on a private network with the rest of this
|
||||
project.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
deployMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
onClick={onAddService}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Rocket size={13} />
|
||||
Deploy
|
||||
<Plus size={15} />
|
||||
Add Service
|
||||
</button>
|
||||
{contextService.status !== 'running' && (
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node context menu */}
|
||||
{contextMenu && contextService && (
|
||||
<div
|
||||
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] panel-glass py-1"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
onOpenService(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Open service
|
||||
</button>
|
||||
{(contextService.domain || contextService.publicUrl) && (
|
||||
<a
|
||||
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<Globe size={13} />
|
||||
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
startMutation.mutate(contextService.id);
|
||||
deployMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Rocket size={13} />
|
||||
Deploy
|
||||
</button>
|
||||
{contextService.status !== 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
startMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={13} />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
restartMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={13} />
|
||||
Start
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
restartMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={13} />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
stopMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Square size={13} />
|
||||
Stop
|
||||
</button>
|
||||
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
|
||||
deleteMutation.mutate(contextService.id);
|
||||
}
|
||||
stopMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete
|
||||
<Square size={13} />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
|
||||
{selectedService ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||
<Box size={14} className="text-[var(--accent-primary)]" />
|
||||
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
|
||||
deleteMutation.mutate(contextService.id);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{selectedService.name}</span>
|
||||
<span className="ml-2 text-xs text-[var(--text-tertiary)]">({selectedService.type})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--surface-muted)] text-xs text-[var(--text-tertiary)]">
|
||||
<Link2 size={10} />
|
||||
<span>{selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { inferAutoConnections } from '../auto-connections';
|
||||
|
||||
export const NODE_WIDTH = 240;
|
||||
export const NODE_HEIGHT = 104;
|
||||
export const COL_GAP = 340;
|
||||
export const ROW_GAP = 172;
|
||||
|
||||
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
|
||||
|
||||
// Layered left-to-right layout: a service's depth is the longest chain of
|
||||
// dependencies beneath it, so consumers sit left and datastores right.
|
||||
// Unconnected services get their own trailing column separated by a gap.
|
||||
export function computeLayeredLayout(
|
||||
services: ServiceEntity[],
|
||||
edges: LinkEdge[],
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
if (services.length === 0) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
const ids = new Set(services.map((service) => service.id));
|
||||
const outgoing = new Map<string, string[]>();
|
||||
const connected = new Set<string>();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
|
||||
continue;
|
||||
}
|
||||
connected.add(edge.sourceServiceId);
|
||||
connected.add(edge.targetServiceId);
|
||||
const list = outgoing.get(edge.sourceServiceId) ?? [];
|
||||
list.push(edge.targetServiceId);
|
||||
outgoing.set(edge.sourceServiceId, list);
|
||||
}
|
||||
|
||||
const depthMemo = new Map<string, number>();
|
||||
const depthOf = (id: string, trail: Set<string>): number => {
|
||||
const memoized = depthMemo.get(id);
|
||||
if (memoized !== undefined) {
|
||||
return memoized;
|
||||
}
|
||||
if (trail.has(id)) {
|
||||
return 0; // cycle guard
|
||||
}
|
||||
trail.add(id);
|
||||
let best = 0;
|
||||
for (const target of outgoing.get(id) ?? []) {
|
||||
best = Math.max(best, depthOf(target, trail) + 1);
|
||||
}
|
||||
trail.delete(id);
|
||||
depthMemo.set(id, best);
|
||||
return best;
|
||||
};
|
||||
|
||||
const connectedServices = services
|
||||
.filter((service) => connected.has(service.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const looseServices = services
|
||||
.filter((service) => !connected.has(service.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
|
||||
const maxDepth = Math.max(0, ...depthById.values());
|
||||
|
||||
// Column = maxDepth - depth so deeper consumers render further left.
|
||||
const columns = new Map<number, ServiceEntity[]>();
|
||||
for (const service of connectedServices) {
|
||||
const col = maxDepth - (depthById.get(service.id) ?? 0);
|
||||
const list = columns.get(col) ?? [];
|
||||
list.push(service);
|
||||
columns.set(col, list);
|
||||
}
|
||||
|
||||
const connectedColCount = columns.size;
|
||||
const maxRows = Math.max(
|
||||
1,
|
||||
...Array.from(columns.values()).map((list) => list.length),
|
||||
looseServices.length,
|
||||
);
|
||||
|
||||
for (const [col, list] of columns) {
|
||||
const offsetY = ((maxRows - list.length) * ROW_GAP) / 2;
|
||||
list.forEach((service, row) => {
|
||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||
});
|
||||
}
|
||||
|
||||
looseServices.forEach((service, row) => {
|
||||
const col = connectedColCount > 0 ? connectedColCount : 0;
|
||||
const offsetY = ((maxRows - looseServices.length) * ROW_GAP) / 2;
|
||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function layoutFromVariables(
|
||||
services: ServiceEntity[],
|
||||
variablesByService: Record<string, ServiceVariable[]>,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const links = inferAutoConnections(services, variablesByService);
|
||||
return computeLayeredLayout(
|
||||
services,
|
||||
links.map((link) => link.edge),
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import 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<ServiceNodeData, 'serviceNode'>;
|
||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
||||
|
||||
function renderServiceTypeIcon(type: string, size = 18) {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return <Globe size={size} />;
|
||||
case 'database':
|
||||
return <Database size={size} />;
|
||||
case 'worker':
|
||||
return <Terminal size={size} />;
|
||||
default:
|
||||
return <Box size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return 'var(--accent-primary)';
|
||||
default:
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
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<ServiceNodeType>) {
|
||||
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 (
|
||||
<div
|
||||
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
|
||||
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
|
||||
}`}
|
||||
style={{ minWidth: 220, maxWidth: 280 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
style={{ background: `${typeColor}10` }}
|
||||
style={{ background: `${typeColor}08` }}
|
||||
/>
|
||||
|
||||
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
@@ -105,22 +83,22 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
style={{ background: stateColor }}
|
||||
/>
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center gap-3 mb-2.5">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
||||
selected ? 'ring-2 ring-white/20' : ''
|
||||
}`}
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
background: selected ? typeColor : `${typeColor}20`,
|
||||
color: selected ? 'white' : typeColor,
|
||||
background: selected ? typeColor : `${typeColor}1c`,
|
||||
color: selected ? 'var(--bg-void)' : typeColor,
|
||||
}}
|
||||
>
|
||||
{renderServiceTypeIcon(service.type, 18)}
|
||||
{isTransient ? <Loader2 size={16} className="animate-spin" /> : serviceTypeIcon(service.type, 16)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
{service.name}
|
||||
</h4>
|
||||
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</p>
|
||||
@@ -133,7 +111,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
|
||||
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
@@ -141,12 +119,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
{internalAddr}
|
||||
</span>
|
||||
)}
|
||||
@@ -164,14 +142,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
|
||||
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
|
||||
{statusLabel(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
{service.image && (
|
||||
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
|
||||
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
|
||||
{service.image}
|
||||
</span>
|
||||
)}
|
||||
@@ -181,41 +159,68 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
||||
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [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 (
|
||||
<div
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
|
||||
style={{
|
||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
|
||||
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
background: 'rgba(232,49,106,0.03)',
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
style={{
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
style={{
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(data.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraft(data.title);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{data.title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
|
||||
|
||||
export function serviceTypeIcon(type: string, size = 18) {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return <Globe size={size} />;
|
||||
case 'database':
|
||||
return <Database size={size} />;
|
||||
case 'worker':
|
||||
return <Terminal size={size} />;
|
||||
case 'cron':
|
||||
return <Clock size={size} />;
|
||||
default:
|
||||
return <Box size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return '#f2c94c';
|
||||
case 'cron':
|
||||
return '#f2994a';
|
||||
default:
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
|
||||
export function createDefaultCanvasMetadata(
|
||||
services: ServiceEntity[],
|
||||
layout?: Map<string, CanvasPoint>,
|
||||
): ProjectCanvasMetadata {
|
||||
const nodes = services.map((service, index) => {
|
||||
const 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,
|
||||
},
|
||||
|
||||
@@ -654,131 +654,113 @@ export function ProjectWorkspacePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const canvasView = activeView === 'canvas';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span className="hidden sm:inline">Projects</span>
|
||||
</button>
|
||||
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
|
||||
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
|
||||
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
|
||||
</span>
|
||||
|
||||
{/* View tabs */}
|
||||
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
|
||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||
const active = activeView === item.key;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
key={item.key}
|
||||
onClick={() => setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('view', item.key);
|
||||
return next;
|
||||
})}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
|
||||
active
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Projects</span>
|
||||
<Icon size={13} />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-[var(--border-subtle)]" />
|
||||
<div>
|
||||
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
||||
<LiveIndicator isLive={runningServices > 0} />
|
||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
||||
<Clock size={12} />
|
||||
<span>{formatRelative(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search / Command Palette */}
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<Search size={14} />
|
||||
<span>Search...</span>
|
||||
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Add Service Button */}
|
||||
{!isDemoMode && (
|
||||
<button
|
||||
onClick={openAddService}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<div className="hidden xl:block">
|
||||
<LiveIndicator isLive={runningServices > 0} />
|
||||
</div>
|
||||
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<Clock size={11} />
|
||||
{formatRelative(project.updatedAt)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<Search size={12} />
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
{!isDemoMode && (
|
||||
<button
|
||||
onClick={openAddService}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Banner */}
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active — using sample data for preview</span>
|
||||
{/* Content */}
|
||||
{canvasView ? (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<ProjectCanvas
|
||||
projectId={project.id}
|
||||
services={services}
|
||||
variablesByService={variablesByService}
|
||||
onAddService={openAddService}
|
||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
||||
readOnly={isDemoMode}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
|
||||
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
|
||||
<Sparkles size={12} />
|
||||
Demo data
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 pt-6">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active - using sample data for preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="lg:sticky lg:top-6 lg:h-fit">
|
||||
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
|
||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||
const active = activeView === item.key;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('view', item.key);
|
||||
return next;
|
||||
})}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
|
||||
active
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
{active && (
|
||||
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
)}
|
||||
<div className="w-full px-8 py-6">
|
||||
<section className="min-w-0">
|
||||
{activeView === 'canvas' && (
|
||||
<ProjectCanvas
|
||||
projectId={project.id}
|
||||
services={services}
|
||||
variablesByService={variablesByService}
|
||||
onAddService={openAddService}
|
||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === 'observability' && (
|
||||
<div className="space-y-6">
|
||||
{/* Health Summary */}
|
||||
@@ -965,6 +947,7 @@ export function ProjectWorkspacePage() {
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Service Dialog */}
|
||||
{!isDemoMode && (
|
||||
|
||||
@@ -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<string, { x: number; y: number }>,
|
||||
): 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<string>();
|
||||
|
||||
Reference in new issue
Block a user