Skip to content

Commit

Permalink
fix: module page now shows graph view (#4231)
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman authored Jan 29, 2025
1 parent 68625eb commit be65c34
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 52 deletions.
46 changes: 1 addition & 45 deletions frontend/console/src/features/graph/GraphPage.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -46,37 +36,3 @@ export const GraphPage = () => {
</div>
)
}

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[]
}
93 changes: 89 additions & 4 deletions frontend/console/src/features/graph/graph-utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,7 +20,7 @@ interface GraphData {
edges: Edge[]
}

const createNode = (
export const createNode = (
id: string,
label: string,
type: 'groupNode' | 'declNode',
Expand Down Expand Up @@ -38,7 +48,7 @@ const createNode = (
},
})

const createEdge = (
export const createEdge = (
sourceModule: string,
sourceVerb: string | undefined,
targetModule: string,
Expand Down Expand Up @@ -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[]
}
99 changes: 99 additions & 0 deletions frontend/console/src/features/modules/ModuleGraph.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<ReactFlowProvider key={module.name}>
<div className={isDarkMode ? 'dark' : 'light'} style={{ width: '100%', height: '100%', position: 'relative' }}>
<Flow
nodes={layoutedNodes}
edges={layoutedEdges}
nodeTypes={NODE_TYPES}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
fitView
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
nodesConnectable={false}
colorMode={isDarkMode ? 'dark' : 'light'}
>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Controls />
</Flow>
</div>
</ReactFlowProvider>
)
}
49 changes: 46 additions & 3 deletions frontend/console/src/features/modules/ModulePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
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'

export const ModulePanel = () => {
const { moduleName } = useParams()
const modules = useStreamModules()
const ref = useRef<HTMLDivElement>(null)
const [selectedNode, setSelectedNode] = useState<FTLNode | null>(null)
const [selectedModuleName, setSelectedModuleName] = useState<string | null>(null)

const module = useMemo(() => {
if (!modules?.data) {
Expand All @@ -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 = (
<div ref={ref} className='p-4 h-[calc(100vh-64px)] flex flex-col'>
<div className='flex-1 min-h-0 grid grid-rows-2 gap-4'>
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden'>
<div className='flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800'>
<CodeIcon className='size-4 text-gray-500 dark:text-gray-400' />
<span className='text-sm font-medium text-gray-700 dark:text-gray-200'>Schema</span>
</div>
<div className='p-4 overflow-auto h-[calc(100%-40px)]'>
<Schema schema={module.schema} moduleName={module.name} />
</div>
</div>
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden'>
<div className='flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800'>
<CellsIcon className='size-4 text-gray-500 dark:text-gray-400' />
<span className='text-sm font-medium text-gray-700 dark:text-gray-200'>Graph</span>
</div>
<div className='h-[calc(100%-40px)]'>
<ModuleGraph module={module} onTapped={handleNodeTapped} />
</div>
</div>
</div>
</div>
)

return (
<div ref={ref} className='mt-4 mx-4 h-full'>
<Schema schema={module.schema} moduleName={module.name} />
<div className='h-[calc(100vh-64px)] flex'>
<div className='flex-1 min-h-0'>
<ResizablePanels
mainContent={mainContent}
rightPanelHeader={headerForNode(selectedNode, selectedModuleName)}
rightPanelPanels={panelsForNode(selectedNode, selectedModuleName)}
/>
</div>
</div>
)
}

0 comments on commit be65c34

Please sign in to comment.