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
@@ -136,7 +136,10 @@ export function ServiceDetailPage() {
|
|||||||
});
|
});
|
||||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
|
const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
|
||||||
|
const requested = searchParams.get('section');
|
||||||
|
return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
|
||||||
|
});
|
||||||
const [logTail, setLogTail] = useState('100');
|
const [logTail, setLogTail] = useState('100');
|
||||||
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
||||||
const [bulkOpen, setBulkOpen] = useState(false);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
|||||||
@@ -34,31 +34,32 @@ import {
|
|||||||
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||||
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
|
||||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
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 { serviceAccent } from './service-visuals';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Layers,
|
Layers,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minus,
|
Minus,
|
||||||
Link2,
|
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
Rocket,
|
Rocket,
|
||||||
Trash2,
|
Trash2,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Globe,
|
Globe,
|
||||||
X,
|
|
||||||
Loader2,
|
|
||||||
Wand2,
|
Wand2,
|
||||||
|
Pencil,
|
||||||
|
Ungroup,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
type CanvasProps = {
|
type CanvasProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
services: ServiceEntity[];
|
services: ServiceEntity[];
|
||||||
variablesByService: Record<string, ServiceVariable[]>;
|
variablesByService: Record<string, ServiceVariable[]>;
|
||||||
onAddService: () => void;
|
onAddService: () => void;
|
||||||
onOpenService: (serviceId: string) => void;
|
onOpenService: (serviceId: string) => void;
|
||||||
|
onOpenSection?: (serviceId: string, section: string) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,9 +78,12 @@ const nodeTypes: NodeTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ContextMenuState = {
|
type ContextMenuState = {
|
||||||
serviceId: string;
|
kind: 'service' | 'group' | 'pane';
|
||||||
|
targetId?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
flowX?: number;
|
||||||
|
flowY?: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
function toFlowNodes(
|
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 {
|
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
|
||||||
const groups: CanvasGroup[] = [];
|
const groups: CanvasGroup[] = [];
|
||||||
const layouts: CanvasNodeLayout[] = [];
|
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 wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const persistTimeout = useRef<number | null>(null);
|
const persistTimeout = useRef<number | null>(null);
|
||||||
const hydratedRef = useRef(false);
|
const hydratedRef = useRef(false);
|
||||||
@@ -523,26 +574,32 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
|
|
||||||
if (targetGroup) {
|
if (targetGroup) {
|
||||||
const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position;
|
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));
|
setNodes((current) => {
|
||||||
const relativeY = Math.max(30, Math.min(targetHeight - NODE_HEIGHT - 12, basePosition.y - groupBase.y));
|
const slot = groupSlot(
|
||||||
|
current,
|
||||||
|
targetGroup.id,
|
||||||
|
targetGroup,
|
||||||
|
basePosition.x - groupBase.x,
|
||||||
|
basePosition.y - groupBase.y,
|
||||||
|
movedNode.id,
|
||||||
|
);
|
||||||
|
|
||||||
setNodes((current) =>
|
return current.map((node) => {
|
||||||
current.map((node) => {
|
if (node.id === targetGroup.id && node.type === 'groupNode') {
|
||||||
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
|
return { ...node, style: { ...node.style, width: slot.width, height: slot.height } };
|
||||||
return node;
|
|
||||||
}
|
}
|
||||||
|
if (node.id === movedNode.id && node.type === 'serviceNode') {
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
parentId: targetGroup.id,
|
parentId: targetGroup.id,
|
||||||
extent: 'parent',
|
extent: 'parent' as const,
|
||||||
position: { x: relativeX, y: relativeY },
|
position: { x: slot.x, y: slot.y },
|
||||||
};
|
};
|
||||||
}),
|
}
|
||||||
);
|
return node;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -551,6 +608,22 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
return;
|
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) =>
|
setNodes((current) =>
|
||||||
current.map((node) => {
|
current.map((node) => {
|
||||||
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
|
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 addGroup = useCallback(
|
||||||
const bounds = wrapperRef.current?.getBoundingClientRect();
|
(at?: { x: number; y: number }) => {
|
||||||
const center = bounds
|
const bounds = wrapperRef.current?.getBoundingClientRect();
|
||||||
? screenToFlowPosition({
|
const center =
|
||||||
x: bounds.left + bounds.width / 2,
|
at ??
|
||||||
y: bounds.top + bounds.height / 2,
|
(bounds
|
||||||
})
|
? screenToFlowPosition({
|
||||||
: { x: 180, y: 140 };
|
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) => [
|
setNodes((current) => [
|
||||||
...current,
|
{
|
||||||
{
|
id,
|
||||||
id,
|
type: 'groupNode',
|
||||||
type: 'groupNode',
|
position: {
|
||||||
position: {
|
x: center.x - GROUP_DEFAULT_WIDTH / 2,
|
||||||
x: center.x - GROUP_DEFAULT_WIDTH / 2,
|
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
|
||||||
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
|
},
|
||||||
},
|
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup },
|
||||||
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}`, onRename: renameGroup },
|
draggable: true,
|
||||||
draggable: true,
|
selectable: true,
|
||||||
selectable: true,
|
style: {
|
||||||
style: {
|
width: GROUP_DEFAULT_WIDTH,
|
||||||
width: GROUP_DEFAULT_WIDTH,
|
height: GROUP_DEFAULT_HEIGHT,
|
||||||
height: GROUP_DEFAULT_HEIGHT,
|
},
|
||||||
},
|
} satisfies GroupCanvasNode,
|
||||||
} satisfies GroupCanvasNode,
|
// Groups must precede their children in the array — keep it first.
|
||||||
]);
|
...current,
|
||||||
}, [screenToFlowPosition, setNodes, renameGroup]);
|
]);
|
||||||
|
|
||||||
|
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
|
// Env filter hides nodes via React Flow's `hidden` flag — positions and
|
||||||
// persisted layout stay intact for services outside the current filter.
|
// persisted layout stay intact for services outside the current filter.
|
||||||
@@ -613,24 +810,53 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
[nodes, visibleIds],
|
[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(
|
const onNodeContextMenu = useCallback(
|
||||||
(event: MouseEvent, node: CanvasNode) => {
|
(event: MouseEvent, node: CanvasNode) => {
|
||||||
if (node.type !== 'serviceNode') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const bounds = wrapperRef.current?.getBoundingClientRect();
|
if (node.type === 'serviceNode') {
|
||||||
setSelectedServiceId(node.id);
|
setSelectedServiceId(node.id);
|
||||||
const menuW = 190;
|
setContextMenu({ kind: 'service', targetId: node.id, ...menuPosition(event.clientX, event.clientY) });
|
||||||
const menuH = 230;
|
} else if (node.type === 'groupNode') {
|
||||||
const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW));
|
setContextMenu({ kind: 'group', targetId: node.id, ...menuPosition(event.clientX, event.clientY, 190, 140) });
|
||||||
const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH));
|
}
|
||||||
setContextMenu({ serviceId: node.id, x, y });
|
|
||||||
},
|
},
|
||||||
[],
|
[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 selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
||||||
const selectedConnections = useMemo(() => {
|
const selectedConnections = useMemo(() => {
|
||||||
@@ -686,6 +912,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onNodeContextMenu={onNodeContextMenu}
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
@@ -734,7 +961,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="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)]"
|
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"
|
title="Add group"
|
||||||
>
|
>
|
||||||
@@ -759,155 +986,36 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
|
|
||||||
{/* Inspector (right) */}
|
{/* Inspector (right) */}
|
||||||
{selectedService && (
|
{selectedService && (
|
||||||
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[300px] panel-glass flex flex-col overflow-hidden">
|
<ServiceInspector
|
||||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
|
service={selectedService}
|
||||||
<div
|
variables={variablesByService[selectedService.id] ?? []}
|
||||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
|
connections={selectedConnections}
|
||||||
style={{ background: `${serviceAccent(selectedService)}1c`, color: serviceAccent(selectedService) }}
|
groups={inspectorGroups}
|
||||||
>
|
groupId={selectedNode?.parentId}
|
||||||
<ServiceIcon service={selectedService} size={16} />
|
readOnly={readOnly}
|
||||||
</div>
|
actions={{
|
||||||
<div className="min-w-0 flex-1">
|
deploy: () => deployMutation.mutate(selectedService.id),
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{selectedService.name}</h3>
|
start: () => startMutation.mutate(selectedService.id),
|
||||||
<p className="text-[11px] text-[var(--text-tertiary)]">
|
restart: () => restartMutation.mutate(selectedService.id),
|
||||||
{selectedService.type} · {selectedService.environment ?? 'production'}
|
stop: () => stopMutation.mutate(selectedService.id),
|
||||||
</p>
|
remove: () => {
|
||||||
</div>
|
if (window.confirm(`Delete service "${selectedService.name}"? Its containers will be removed.`)) {
|
||||||
<button
|
deleteMutation.mutate(selectedService.id);
|
||||||
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)]"
|
pending: actionPending,
|
||||||
title="Close (Esc)"
|
deployPending: deployMutation.isPending,
|
||||||
>
|
restartPending: restartMutation.isPending,
|
||||||
<X size={14} />
|
}}
|
||||||
</button>
|
onClose={() => setSelectedServiceId(null)}
|
||||||
</div>
|
onOpenService={onOpenService}
|
||||||
|
onOpenSection={(serviceId, section) =>
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
onOpenSection ? onOpenSection(serviceId, section) : onOpenService(serviceId)
|
||||||
{selectedService.image && (
|
}
|
||||||
<div>
|
onSelectPeer={setSelectedServiceId}
|
||||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-1">Image</p>
|
onAssignGroup={assignToGroup}
|
||||||
<p className="mono text-[11px] text-[var(--text-secondary)] break-all">{selectedService.image}</p>
|
onNewGroupFor={newGroupForService}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
@@ -935,12 +1043,100 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Node context menu */}
|
{/* Context menus — service node, group frame, or empty pane */}
|
||||||
{contextMenu && contextService && (
|
{contextMenu && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] panel-glass py-1"
|
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 }}
|
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">
|
<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-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
|
||||||
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</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>
|
</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 = {
|
export type GroupNodeData = {
|
||||||
title: string;
|
title: string;
|
||||||
onRename?: (groupId: string, title: string) => void;
|
onRename?: (groupId: string, title: string) => void;
|
||||||
|
renameNonce?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
@@ -170,6 +171,16 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(data.title);
|
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 commit = () => {
|
||||||
const next = draft.trim() || data.title;
|
const next = draft.trim() || data.title;
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
|||||||
@@ -620,6 +620,8 @@ export function ProjectWorkspacePage() {
|
|||||||
isDemoMode
|
isDemoMode
|
||||||
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
||||||
: `/projects/${projectId}/services/${serviceId}`;
|
: `/projects/${projectId}/services/${serviceId}`;
|
||||||
|
const serviceSectionHref = (serviceId: string, section: string) =>
|
||||||
|
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
|
||||||
|
|
||||||
if (!isDemoMode && projectQuery.isLoading) {
|
if (!isDemoMode && projectQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -738,6 +740,7 @@ export function ProjectWorkspacePage() {
|
|||||||
variablesByService={variablesByService}
|
variablesByService={variablesByService}
|
||||||
onAddService={openAddService}
|
onAddService={openAddService}
|
||||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
||||||
|
onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))}
|
||||||
readOnly={isDemoMode}
|
readOnly={isDemoMode}
|
||||||
/>
|
/>
|
||||||
{isDemoMode && (
|
{isDemoMode && (
|
||||||
|
|||||||
Reference in new issue
Block a user