feat(canvas): full service cockpit on left-click + real grouping

The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.

Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.

Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-23 18:50:38 +02:00
1 parent d9bfd8a2fd
commit 7fc6ed1215
5 files changed
+847 -217

No files matched your search

@@ -34,31 +34,32 @@ import {
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 { ServiceIcon } from './ServiceIcon';
import { ServiceInspector, type InspectorGroup } from './inspector';
import { serviceAccent } from './service-visuals';
import {
Plus,
Layers,
Maximize2,
Minus,
Link2,
Play,
Square,
Rocket,
Trash2,
ArrowUpRight,
Globe,
X,
Loader2,
Wand2,
Pencil,
Ungroup,
} from 'lucide-react';
type CanvasProps = {
projectId: string;
services: ServiceEntity[];
variablesByService: Record<string, ServiceVariable[]>;
onAddService: () => void;
onOpenService: (serviceId: string) => void;
onOpenSection?: (serviceId: string, section: string) => void;
readOnly?: boolean;
};
@@ -77,9 +78,12 @@ const nodeTypes: NodeTypes = {
};
type ContextMenuState = {
serviceId: string;
kind: 'service' | 'group' | 'pane';
targetId?: string;
x: number;
y: number;
flowX?: number;
flowY?: number;
} | null;
function toFlowNodes(
@@ -172,6 +176,53 @@ function toFlowEdges(
});
}
const GROUP_PAD_X = 28;
const GROUP_PAD_TOP = 56;
const GROUP_PAD_BOTTOM = 24;
const GROUP_ROW_GAP = 16;
// 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 fits =
desiredX >= GROUP_PAD_X &&
desiredY >= GROUP_PAD_TOP &&
desiredX + NODE_WIDTH <= width - GROUP_PAD_X &&
desiredY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
if (fits) {
return { x: desiredX, y: desiredY, 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[] = [];
@@ -258,7 +309,7 @@ function ZoomControls({ onTidy }: { onTidy: () => void }) {
);
}
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, readOnly }: CanvasProps) {
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService, onOpenSection, readOnly }: CanvasProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
@@ -523,26 +574,32 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
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 - NODE_WIDTH - 12, basePosition.x - groupBase.x));
const relativeY = Math.max(30, Math.min(targetHeight - 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 } };
}
return {
...node,
parentId: targetGroup.id,
extent: 'parent',
position: { x: relativeX, y: relativeY },
};
}),
);
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;
}
@@ -551,6 +608,22 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
return;
}
// Still inside its own frame — keep membership, the relative move stands.
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) {
return;
}
}
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
@@ -569,39 +642,163 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}),
);
},
[getGroupUnderPoint, getInternalNode, setNodes],
[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}`, onRename: renameGroup },
draggable: true,
selectable: true,
style: {
width: GROUP_DEFAULT_WIDTH,
height: GROUP_DEFAULT_HEIGHT,
},
} satisfies GroupCanvasNode,
]);
}, [screenToFlowPosition, setNodes, renameGroup]);
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) =>
current.map((entry) =>
entry.id === serviceId && entry.type === 'serviceNode'
? { ...entry, parentId: undefined, extent: undefined, position: abs }
: 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 unparented = current.map((entry) => {
if (entry.type !== 'serviceNode' || entry.parentId !== groupId) {
return entry;
}
const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position;
return { ...entry, parentId: undefined, extent: undefined, position: abs };
});
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.
@@ -613,24 +810,53 @@ 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 contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
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(() => {
@@ -686,6 +912,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}
}}
onNodeContextMenu={onNodeContextMenu}
onPaneContextMenu={onPaneContextMenu}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
panOnDrag
@@ -734,7 +961,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
)}
<button
type="button"
onClick={addGroup}
onClick={() => addGroup()}
className="canvas-pill canvas-pill-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Add group"
>
@@ -759,155 +986,36 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* 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: `${serviceAccent(selectedService)}1c`, color: serviceAccent(selectedService) }}
>
<ServiceIcon service={selectedService} size={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>
</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>
<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={() => {
if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) {
deleteMutation.mutate(selectedService.id);
}
}}
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"
>
<Trash2 size={11} />
Delete service
</button>
</div>
</aside>
<ServiceInspector
service={selectedService}
variables={variablesByService[selectedService.id] ?? []}
connections={selectedConnections}
groups={inspectorGroups}
groupId={selectedNode?.parentId}
readOnly={readOnly}
actions={{
deploy: () => deployMutation.mutate(selectedService.id),
start: () => startMutation.mutate(selectedService.id),
restart: () => restartMutation.mutate(selectedService.id),
stop: () => stopMutation.mutate(selectedService.id),
remove: () => {
if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) {
deleteMutation.mutate(selectedService.id);
}
},
pending: actionPending,
deployPending: deployMutation.isPending,
restartPending: restartMutation.isPending,
}}
onClose={() => setSelectedServiceId(null)}
onOpenService={onOpenService}
onOpenSection={(serviceId, section) =>
onOpenSection ? onOpenSection(serviceId, section) : onOpenService(serviceId)
}
onSelectPeer={setSelectedServiceId}
onAssignGroup={assignToGroup}
onNewGroupFor={newGroupForService}
/>
)}
{/* Empty state */}
@@ -935,12 +1043,100 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</div>
)}
{/* Node context menu */}
{contextMenu && contextService && (
{/* Context menus — service node, group frame, or empty pane */}
{contextMenu && (
<div
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] panel-glass py-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.kind === 'pane' && (
<>
<button
type="button"
onClick={() => {
setContextMenu(null);
onAddService();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Plus size={13} />
Add service
</button>
<button
type="button"
onClick={() => {
const at =
contextMenu.flowX !== undefined && contextMenu.flowY !== undefined
? { x: contextMenu.flowX, y: contextMenu.flowY }
: undefined;
setContextMenu(null);
addGroup(at);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Layers size={13} />
Add group here
</button>
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
onClick={() => {
setContextMenu(null);
tidyLayout();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Wand2 size={13} />
Tidy layout
</button>
<button
type="button"
onClick={() => {
setContextMenu(null);
void fitView({ padding: 0.18, duration: 240 });
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Maximize2 size={13} />
Fit view
</button>
</div>
</>
)}
{contextMenu.kind === 'group' && contextGroup && (
<>
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextGroup.data.title}</p>
<p className="text-[10px] text-[var(--text-tertiary)]">Group</p>
</div>
<button
type="button"
onClick={() => {
const id = contextGroup.id;
setContextMenu(null);
requestGroupRename(id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Pencil size={13} />
Rename
</button>
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
onClick={() => deleteGroup(contextGroup.id)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors"
>
<Ungroup size={13} />
Ungroup (keep services)
</button>
</div>
</>
)}
{contextMenu.kind === 'service' && contextService && (
<>
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
@@ -1037,6 +1233,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</div>
</>
)}
</>
)}
</div>
)}
</div>
@@ -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 (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
{action}
</div>
{children}
</div>
);
}
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
>
{children}
<ArrowUpRight size={9} />
</button>
);
}
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 (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] 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: `${accent}1c`, color: accent }}
>
<ServiceIcon service={service} size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
<span>· {service.type}</span>
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
</div>
</div>
<button
type="button"
onClick={onClose}
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">
{(publicUrl || service.port) && (
<Section title="Address">
<div className="flex items-center gap-1.5 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port}
</span>
)}
<button
type="button"
onClick={copyAddress}
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
title="Copy address"
>
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
</button>
</div>
{publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
{service.name}:{service.port}
</p>
) : null}
</Section>
)}
<Section title="Group">
<div className="flex items-center gap-2">
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
<select
value={groupId ?? ''}
onChange={(event) => {
const next = event.target.value;
if (next === '__new__') {
onNewGroupFor(service.id);
return;
}
onAssignGroup(service.id, next === '' ? null : next);
}}
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
>
<option value="">Ungrouped</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.title}
</option>
))}
<option value="__new__">+ New group…</option>
</select>
</div>
</Section>
<Section
title={`Variables (${variables.length})`}
action={
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
Manage
</SectionLink>
}
>
{variables.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
) : (
<div className="space-y-1">
{variables.slice(0, 6).map((variable) => (
<div
key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
>
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key}
</span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value}
</span>
</div>
))}
{variables.length > 6 && (
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
)}
</div>
)}
</Section>
<Section
title="Config"
action={
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
Edit
</SectionLink>
}
>
{visibleConfig.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
) : (
<dl className="space-y-1">
{visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value}
</dd>
</div>
))}
{(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div>
)}
</dl>
)}
</Section>
<Section title={`Connections (${connections.length})`}>
{connections.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">
{connections.map((conn) => (
<button
key={conn.id}
type="button"
onClick={() => onSelectPeer(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>
)}
</Section>
{service.updatedAt && (
<p className="text-[10px] text-[var(--text-muted)] pt-1">
Updated {formatRelative(service.updatedAt)}
</p>
)}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button
type="button"
onClick={() => onOpenService(service.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={actions.pending || readOnly}
onClick={actions.deploy}
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"
>
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy
</button>
{service.status !== 'running' ? (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.start}
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={actions.pending || readOnly}
onClick={actions.restart}
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"
>
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart
</button>
)}
<button
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop}
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={actions.pending || readOnly}
onClick={actions.remove}
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"
>
<Trash2 size={11} />
Delete service
</button>
</div>
</aside>
);
}
@@ -15,6 +15,7 @@ export type ServiceNodeData = {
export type GroupNodeData = {
title: string;
onRename?: (groupId: string, title: string) => void;
renameNonce?: number;
};
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
@@ -170,6 +171,16 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
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);