mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user