diff --git a/frontend/console/src/features/graph/GraphPage.tsx b/frontend/console/src/features/graph/GraphPage.tsx index 590f7f02c0..b4cb651cee 100644 --- a/frontend/console/src/features/graph/GraphPage.tsx +++ b/frontend/console/src/features/graph/GraphPage.tsx @@ -1,21 +1,11 @@ import { useState } from 'react' -import { Config, Data, Database, Enum, Module, Secret, Topic, Verb } from '../../protos/xyz/block/ftl/console/v1/console_pb' -import type { ExpandablePanelProps } from '../../shared/components/ExpandablePanel' import { Loader } from '../../shared/components/Loader' import { ResizablePanels } from '../../shared/components/ResizablePanels' -import { configPanels } from '../modules/decls/config/ConfigRightPanels' -import { dataPanels } from '../modules/decls/data/DataRightPanels' -import { databasePanels } from '../modules/decls/database/DatabaseRightPanels' -import { enumPanels } from '../modules/decls/enum/EnumRightPanels' -import { secretPanels } from '../modules/decls/secret/SecretRightPanels' -import { topicPanels } from '../modules/decls/topic/TopicRightPanels' -import { verbPanels } from '../modules/decls/verb/VerbRightPanel' import { useModules } from '../modules/hooks/use-modules' import { Timeline } from '../timeline/Timeline' import { GraphPane } from './GraphPane' -import { modulePanels } from './ModulePanels' import { headerForNode } from './RightPanelHeader' -import type { FTLNode } from './graph-utils' +import { type FTLNode, panelsForNode } from './graph-utils' export const GraphPage = () => { const modules = useModules() @@ -46,37 +36,3 @@ export const GraphPage = () => { ) } - -const panelsForNode = (node: FTLNode | null, moduleName: string | null) => { - if (node instanceof Module) { - return modulePanels(node) - } - - // If no module name is provided, we can't show the panels - if (!moduleName) { - return [] as ExpandablePanelProps[] - } - - if (node instanceof Config) { - return configPanels(moduleName, node, false) - } - if (node instanceof Secret) { - return secretPanels(moduleName, node, false) - } - if (node instanceof Database) { - return databasePanels(moduleName, node, false) - } - if (node instanceof Enum) { - return enumPanels(moduleName, node, false) - } - if (node instanceof Data) { - return dataPanels(moduleName, node, false) - } - if (node instanceof Topic) { - return topicPanels(moduleName, node, false) - } - if (node instanceof Verb) { - return verbPanels(moduleName, node, false) - } - return [] as ExpandablePanelProps[] -} diff --git a/frontend/console/src/features/graph/graph-utils.ts b/frontend/console/src/features/graph/graph-utils.ts index 5f99436d88..1699be9ed5 100644 --- a/frontend/console/src/features/graph/graph-utils.ts +++ b/frontend/console/src/features/graph/graph-utils.ts @@ -1,6 +1,16 @@ import type { Edge, Node } from '@xyflow/react' -import type { Config, Data, Database, Enum, Module, Secret, Topic, Verb } from '../../protos/xyz/block/ftl/console/v1/console_pb' +import * as dagre from 'dagre' +import { Config, Data, Database, Enum, Module, Secret, Topic, Verb } from '../../protos/xyz/block/ftl/console/v1/console_pb' +import type { ExpandablePanelProps } from '../../shared/components/ExpandablePanel' +import { configPanels } from '../modules/decls/config/ConfigRightPanels' +import { dataPanels } from '../modules/decls/data/DataRightPanels' +import { databasePanels } from '../modules/decls/database/DatabaseRightPanels' +import { enumPanels } from '../modules/decls/enum/EnumRightPanels' +import { secretPanels } from '../modules/decls/secret/SecretRightPanels' +import { topicPanels } from '../modules/decls/topic/TopicRightPanels' +import { verbPanels } from '../modules/decls/verb/VerbRightPanel' import type { StreamModulesResult } from '../modules/hooks/use-stream-modules' +import { modulePanels } from './ModulePanels' import { getNodeBackgroundColor } from './graph-styles' export type FTLNode = Module | Verb | Secret | Config | Data | Database | Topic | Enum @@ -10,7 +20,7 @@ interface GraphData { edges: Edge[] } -const createNode = ( +export const createNode = ( id: string, label: string, type: 'groupNode' | 'declNode', @@ -38,7 +48,7 @@ const createNode = ( }, }) -const createEdge = ( +export const createEdge = ( sourceModule: string, sourceVerb: string | undefined, targetModule: string, @@ -188,7 +198,82 @@ export const getGraphData = ( return { nodes, edges: Array.from(uniqueEdges.values()) } } -const nodeId = (moduleName: string, name?: string) => { +export const nodeId = (moduleName: string, name?: string) => { if (!name) return moduleName return `${moduleName}.${name}` } + +// Layout function for module-specific graphs (no groups) +export const getModuleLayoutedElements = (nodes: Node[], edges: Edge[], direction = 'LR') => { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + + const nodeWidth = 160 + const nodeHeight = 36 + + dagreGraph.setGraph({ + rankdir: direction, + nodesep: 50, + ranksep: 80, + marginx: 30, + marginy: 30, + }) + + // Add nodes to dagre + for (const node of nodes) { + dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }) + } + + // Add edges to dagre + for (const edge of edges) { + dagreGraph.setEdge(edge.source, edge.target) + } + + // Apply layout + dagre.layout(dagreGraph) + + // Get positions from dagre + for (const node of nodes) { + const nodeWithPosition = dagreGraph.node(node.id) + node.position = { + x: nodeWithPosition.x - nodeWidth / 2, + y: nodeWithPosition.y - nodeHeight / 2, + } + } + + return { nodes, edges } +} + +export const panelsForNode = (node: FTLNode | null, moduleName: string | null) => { + if (node instanceof Module) { + return modulePanels(node) + } + + // If no module name is provided, we can't show the panels + if (!moduleName) { + return [] as ExpandablePanelProps[] + } + + if (node instanceof Config) { + return configPanels(moduleName, node, false) + } + if (node instanceof Secret) { + return secretPanels(moduleName, node, false) + } + if (node instanceof Database) { + return databasePanels(moduleName, node, false) + } + if (node instanceof Enum) { + return enumPanels(moduleName, node, false) + } + if (node instanceof Data) { + return dataPanels(moduleName, node, false) + } + if (node instanceof Topic) { + return topicPanels(moduleName, node, false) + } + if (node instanceof Verb) { + return verbPanels(moduleName, node, false) + } + return [] as ExpandablePanelProps[] +} diff --git a/frontend/console/src/features/modules/ModuleGraph.tsx b/frontend/console/src/features/modules/ModuleGraph.tsx new file mode 100644 index 0000000000..ea46d904a1 --- /dev/null +++ b/frontend/console/src/features/modules/ModuleGraph.tsx @@ -0,0 +1,99 @@ +import { Background, BackgroundVariant, Controls, type Edge, ReactFlow as Flow, type Node, ReactFlowProvider } from '@xyflow/react' +import { useCallback, useMemo, useState } from 'react' +import type React from 'react' +import type { Module } from '../../protos/xyz/block/ftl/console/v1/console_pb' +import { Topology } from '../../protos/xyz/block/ftl/console/v1/console_pb' +import { useUserPreferences } from '../../shared/providers/user-preferences-provider' +import { DeclNode } from '../graph/DeclNode' +import type { FTLNode } from '../graph/graph-utils' +import { getGraphData, getModuleLayoutedElements } from '../graph/graph-utils' +import '@xyflow/react/dist/style.css' + +const NODE_TYPES = { + declNode: DeclNode, +} + +interface ModuleGraphProps { + module: Module + onTapped?: (item: FTLNode | null, moduleName: string | null) => void +} + +export const ModuleGraph = ({ module, onTapped }: ModuleGraphProps) => { + const { isDarkMode } = useUserPreferences() + const [selectedNodeId, setSelectedNodeId] = useState(null) + + const { nodes, edges } = useMemo(() => { + // Create a single-module data structure that matches what getGraphData expects + const moduleData = { + modules: [module], + topology: new Topology({ levels: [] }), + isSuccess: true, + } + const { nodes, edges } = getGraphData(moduleData, isDarkMode, {}, selectedNodeId) + + // Filter out the group node since we don't want it in the module view + return { + nodes: nodes.filter((node) => node.type !== 'groupNode'), + edges, + } + }, [module, isDarkMode, selectedNodeId]) + + const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { + if (!nodes.length) return { nodes: [], edges: [] } + return getModuleLayoutedElements(nodes, edges) + }, [nodes, edges]) + + const onNodeClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + setSelectedNodeId(node.id) + onTapped?.(node.data?.item as FTLNode, node.id) + }, + [onTapped], + ) + + const onEdgeClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + const sourceNode = layoutedNodes.find((n) => n.id === edge.source) + const targetNode = layoutedNodes.find((n) => n.id === edge.target) + + if (sourceNode?.id === selectedNodeId || targetNode?.id === selectedNodeId) { + setSelectedNodeId(null) + onTapped?.(null, null) + } else { + setSelectedNodeId(sourceNode?.id || null) + onTapped?.((sourceNode?.data?.item as FTLNode) || null, sourceNode?.id || null) + } + }, + [onTapped, layoutedNodes, selectedNodeId], + ) + + const onPaneClick = useCallback(() => { + setSelectedNodeId(null) + onTapped?.(null, null) + }, [onTapped]) + + return ( + +
+ + + + +
+
+ ) +} diff --git a/frontend/console/src/features/modules/ModulePanel.tsx b/frontend/console/src/features/modules/ModulePanel.tsx index 4077242c30..b4d05a04ec 100644 --- a/frontend/console/src/features/modules/ModulePanel.tsx +++ b/frontend/console/src/features/modules/ModulePanel.tsx @@ -1,5 +1,10 @@ -import { useEffect, useMemo, useRef } from 'react' +import { CellsIcon, CodeIcon } from 'hugeicons-react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router-dom' +import { ResizablePanels } from '../../shared/components/ResizablePanels' +import { headerForNode } from '../graph/RightPanelHeader' +import { type FTLNode, panelsForNode } from '../graph/graph-utils' +import { ModuleGraph } from './ModuleGraph' import { useStreamModules } from './hooks/use-stream-modules' import { Schema } from './schema/Schema' @@ -7,6 +12,8 @@ export const ModulePanel = () => { const { moduleName } = useParams() const modules = useStreamModules() const ref = useRef(null) + const [selectedNode, setSelectedNode] = useState(null) + const [selectedModuleName, setSelectedModuleName] = useState(null) const module = useMemo(() => { if (!modules?.data) { @@ -19,11 +26,47 @@ export const ModulePanel = () => { ref?.current?.parentElement?.scrollTo({ top: 0, behavior: 'smooth' }) }, [moduleName]) + const handleNodeTapped = (node: FTLNode | null, nodeName: string | null) => { + setSelectedNode(node) + setSelectedModuleName(nodeName) + } + if (!module) return + const mainContent = ( +
+
+
+
+ + Schema +
+
+ +
+
+
+
+ + Graph +
+
+ +
+
+
+
+ ) + return ( -
- +
+
+ +
) }