diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 26b6570..f13cf63 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -136,7 +136,10 @@ export function ServiceDetailPage() { }); const isAdmin = Boolean(profileQuery.data?.isAdmin); - const [activeSection, setActiveSection] = useState('metrics'); + const [activeSection, setActiveSection] = useState(() => { + const requested = searchParams.get('section'); + return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics'; + }); const [logTail, setLogTail] = useState('100'); const [varDrafts, setVarDrafts] = useState(null); const [bulkOpen, setBulkOpen] = useState(false); diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index 5ea433c..023c25f 100644 --- a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx +++ b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx @@ -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; 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(null); const persistTimeout = useRef(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 )} - - -
- {selectedService.image && ( -
-

Image

-

{selectedService.image}

-
- )} - {(selectedService.domain || selectedService.publicUrl || selectedService.port) && ( -
-

Address

- {selectedService.domain || selectedService.publicUrl ? ( - - {(selectedService.domain ?? selectedService.publicUrl ?? '').replace(/^https?:\/\//, '')} - - ) : null} - {selectedService.port ? ( -

- {selectedService.name}:{selectedService.port} -

- ) : null} -
- )} - -
-

- Connections ({selectedConnections.length}) -

- {selectedConnections.length === 0 ? ( -

- No inferred connections. Reference another service via {'{{variable}}'} placeholders in env vars. -

- ) : ( -
- {selectedConnections.map((conn) => ( - - ))} -
- )} -
-
- -
- -
- - {selectedService.status !== 'running' ? ( - - ) : ( - - )} - -
- -
- + 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 )} - {/* Node context menu */} - {contextMenu && contextService && ( + {/* Context menus — service node, group frame, or empty pane */} + {contextMenu && (
+ {contextMenu.kind === 'pane' && ( + <> + + +
+ + +
+ + )} + + {contextMenu.kind === 'group' && contextGroup && ( + <> +
+

{contextGroup.data.title}

+

Group

+
+ +
+ +
+ + )} + + {contextMenu.kind === 'service' && contextService && ( + <>

{contextService.name}

{contextService.status}

@@ -1037,6 +1233,8 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
)} + + )}
)} diff --git a/app/frontend/src/features/workspace/canvas/inspector.tsx b/app/frontend/src/features/workspace/canvas/inspector.tsx new file mode 100644 index 0000000..b3c748c --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/inspector.tsx @@ -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 ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} + +function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +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 ( + + ); +} diff --git a/app/frontend/src/features/workspace/canvas/nodes.tsx b/app/frontend/src/features/workspace/canvas/nodes.tsx index e2f66ee..f6b85da 100644 --- a/app/frontend/src/features/workspace/canvas/nodes.tsx +++ b/app/frontend/src/features/workspace/canvas/nodes.tsx @@ -15,6 +15,7 @@ export type ServiceNodeData = { export type GroupNodeData = { title: string; onRename?: (groupId: string, title: string) => void; + renameNonce?: number; }; export type ServiceNodeType = Node; @@ -170,6 +171,16 @@ export function GroupNode({ id, data }: NodeProps) { 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); diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 126c5bd..9f0dace 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -620,6 +620,8 @@ export function ProjectWorkspacePage() { isDemoMode ? `/projects/${projectId}/services/${serviceId}?demo=1` : `/projects/${projectId}/services/${serviceId}`; + const serviceSectionHref = (serviceId: string, section: string) => + `${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`; if (!isDemoMode && projectQuery.isLoading) { return ( @@ -738,6 +740,7 @@ export function ProjectWorkspacePage() { variablesByService={variablesByService} onAddService={openAddService} onOpenService={(serviceId) => navigate(serviceHref(serviceId))} + onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))} readOnly={isDemoMode} /> {isDemoMode && (