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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user