diff --git a/console/client/package-lock.json b/console/client/package-lock.json index 002c46a3ad..8d7e50daad 100644 --- a/console/client/package-lock.json +++ b/console/client/package-lock.json @@ -16,6 +16,7 @@ "@monaco-editor/react": "4.5.2", "@tailwindcss/forms": "^0.5.6", "@vitejs/plugin-react": "^4.0.4", + "@viz-js/viz": "3.2.0", "highlight.js": "^11.8.0", "json-schema": "0.4.0", "json-schema-faker": "0.5.0-rcv.46", @@ -35,6 +36,7 @@ "@swc/jest": "0.2.29", "@testing-library/jest-dom": "6.0.0", "@testing-library/react": "14.0.0", + "@types/p5": "1.7.0", "@types/react": "18.2.20", "@types/react-dom": "18.2.7", "@typescript-eslint/eslint-plugin": "6.4.0", @@ -2623,6 +2625,12 @@ "integrity": "sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==", "devOptional": true }, + "node_modules/@types/p5": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@types/p5/-/p5-1.7.0.tgz", + "integrity": "sha512-6AnjmW98/edgXB+pYDBx7t6QbCogIG6NUzey93JpGTZA4096C+KjSN8y0sj43QRd9XtblrAhoRXHj1pFZ2vsCg==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -3030,6 +3038,11 @@ "node": ">=0.10.0" } }, + "node_modules/@viz-js/viz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@viz-js/viz/-/viz-3.2.0.tgz", + "integrity": "sha512-CWSfQKrwt9ZrLTUK4rqhqfkdkk89rnsp7hnt3sPs01GmgA9kU2yBUwMYMmb+/w8zKFut/ZtLcMeLeaAQrZ9oOg==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", diff --git a/console/client/package.json b/console/client/package.json index 77d6ab4d97..35d3e534bd 100644 --- a/console/client/package.json +++ b/console/client/package.json @@ -34,6 +34,7 @@ "@monaco-editor/react": "4.5.2", "@tailwindcss/forms": "^0.5.6", "@vitejs/plugin-react": "^4.0.4", + "@viz-js/viz": "3.2.0", "highlight.js": "^11.8.0", "json-schema": "0.4.0", "json-schema-faker": "0.5.0-rcv.46", @@ -49,6 +50,7 @@ "@bufbuild/protoc-gen-connect-es": "0.12.0", "@bufbuild/protoc-gen-es": "1.3.0", "@jest/globals": "29.6.2", + "@types/p5": "1.7.0", "@swc/core": "1.3.77", "@swc/jest": "0.2.29", "@testing-library/jest-dom": "6.0.0", diff --git a/console/client/src/features/modules/ModulesPage.tsx b/console/client/src/features/modules/ModulesPage.tsx index 6e319bb36c..0abc01fa20 100644 --- a/console/client/src/features/modules/ModulesPage.tsx +++ b/console/client/src/features/modules/ModulesPage.tsx @@ -2,51 +2,30 @@ import React from 'react' import { Square3Stack3DIcon } from '@heroicons/react/24/outline' import { PageHeader } from '../../components/PageHeader' import { modulesContext } from '../../providers/modules-provider' -import { Disclosure } from '@headlessui/react' -import { createLayoutDataStructure } from './create-layout-data-structure' - +import { generateDot } from './generate-dot' +import { dotToSVG } from './dot-to-svg' +import { formatSVG } from './format-svg' export const ModulesPage = () => { const modules = React.useContext(modulesContext) - const data = createLayoutDataStructure(modules) - + const dot = generateDot(modules) + const ref = React.useRef(null) + const [viewport, setViewPort] = React.useState() + React.useEffect(() => { + const cur = ref.current + cur && setViewPort(cur) + }, []) + React.useEffect(() => { + const renderSvg = async () => { + const svg = await dotToSVG(dot) + svg && viewport?.replaceChildren(formatSVG(svg)) + } + viewport && void renderSvg() + }, [dot, viewport]) + // console.log(generateDotFile(modules)) return ( - <> +
} title='Modules' /> -
- {data?.map(({ name, style, verbs, 'data-id': dataId }) => ( - - - {name} - - - {verbs.map(({ name, 'data-id': dataId }) => ( -
  • - - {name} -
  • - ))} -
    -
    - ))} -
    - +
    +
    ) } diff --git a/console/client/src/features/modules/constants.ts b/console/client/src/features/modules/constants.ts new file mode 100644 index 0000000000..b213f2f775 --- /dev/null +++ b/console/client/src/features/modules/constants.ts @@ -0,0 +1,11 @@ +export const callIconID = 'call-icon' + +export const callIcon = ` + + + + +` + +export const moduleVerbCls = 'module-verb' +export const moduleTitleCls = 'module-title' diff --git a/console/client/src/features/modules/create-layout-data-structure.ts b/console/client/src/features/modules/create-layout-data-structure.ts deleted file mode 100644 index 05806fa004..0000000000 --- a/console/client/src/features/modules/create-layout-data-structure.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { GetModulesResponse } from '../../protos/xyz/block/ftl/v1/console/console_pb' - -interface Call { - module: string - name: string -} - -interface VerbItem { - name?: string - 'data-id': string - calls: Call[] -} - -interface Item { - 'data-id': string - name: string - style: { marginLeft: number } - verbs: VerbItem[] -} - -type ModuleMap = Map> -interface Graph { - [key: string]: Set -} - -const flattenMap = (map: ModuleMap, graph: Graph): Item[] => { - const sortedKeys = Array.from(map.keys()).sort((a, b) => a - b) - const flattenedList: Item[] = [] - - for (const key of sortedKeys) { - for (const item of map.get(key)!) { - if (key === 0) { - // Items with key 0 have no ancestors, so we just add them directly to the list - flattenedList.push(item) - } else if (graph[item.name]) { - // Find the ancestor for the current item - const ancestorName = Array.from(graph[item.name])[0] - - // Find the index of the ancestor in the flattenedList - let insertionIndex = flattenedList.findIndex((i) => i.name === ancestorName) - - // If ancestor is found, find the position after the last dependent of the ancestor - if (insertionIndex !== -1) { - while ( - insertionIndex + 1 < flattenedList.length && - graph[flattenedList[insertionIndex + 1].name] && - Array.from(graph[flattenedList[insertionIndex + 1].name])[0] === ancestorName - ) { - insertionIndex++ - } - flattenedList.splice(insertionIndex + 1, 0, item) - } else { - // If ancestor is not found, this is a fallback, though ideally this shouldn't happen - flattenedList.push(item) - } - } else { - // If no ancestor is found in the graph, simply push the item to the list - flattenedList.push(item) - } - } - } - - return flattenedList -} - -export const createLayoutDataStructure = (data: GetModulesResponse): Item[] => { - const graph: { [key: string]: Set } = {} - - // Initialize graph with all module names - data.modules.forEach((module) => { - graph[module.name] = new Set() - }) - - // Populate graph with relationships based on verbs' metadata - data.modules.forEach((module) => { - module.verbs.forEach((verbEntry) => { - const verb = verbEntry.verb - verb?.metadata.forEach((metadataEntry) => { - if (metadataEntry.value.case === 'calls') { - metadataEntry.value.value.calls.forEach((call) => { - if (call.module) { - graph[call.module].add(module.name) - } - }) - } - }) - }) - }) - - // Helper function to determine depth of a node in the graph - const determineDepth = ( - node: string, - visited: Set = new Set(), - ancestors: Set = new Set(), - ): number => { - if (ancestors.has(node)) { - // Cycle detected - return 0 - } - - let depth = 0 - ancestors.add(node) - graph[node].forEach((neighbor) => { - if (!visited.has(neighbor)) { - visited.add(neighbor) - depth = Math.max(depth, 1 + determineDepth(neighbor, visited, ancestors)) - } - }) - ancestors.delete(node) - - return depth - } - - const sortedKeys = Object.keys(graph).sort(new Intl.Collator().compare) - const map: Map> = new Map() - - sortedKeys.forEach((moduleName) => { - const moduleData = data.modules.find((mod) => mod.name === moduleName) - if (!moduleData) return - - const depth = determineDepth(moduleName) - const item: Item = { - 'data-id': moduleName, - name: moduleName, - style: { marginLeft: 20 * (depth + 1) }, - verbs: [], - } - - moduleData.verbs.forEach((verbEntry) => { - const verb = verbEntry.verb - const verbItem: VerbItem = { - name: verb?.name, - 'data-id': `${moduleName}.${verb?.name}`, - calls: [], - } - verb?.metadata.forEach((metadataEntry) => { - if (metadataEntry.value.case === 'calls') { - metadataEntry.value.value.calls.forEach((call) => { - verbItem.calls.push({ - module: call.module, - name: call.name, - }) - }) - } - }) - item.verbs.push(verbItem) - }) - map.has(depth) ? map.get(depth)?.add(item) : map.set(depth, new Set([item])) - }) - - return flattenMap(map, graph) -} diff --git a/console/client/src/features/modules/dot-to-svg.ts b/console/client/src/features/modules/dot-to-svg.ts new file mode 100644 index 0000000000..03d7cd71d5 --- /dev/null +++ b/console/client/src/features/modules/dot-to-svg.ts @@ -0,0 +1,10 @@ +import { instance } from '@viz-js/viz' + +export const dotToSVG = async (dot: string) => { + const viz = await instance() + try { + return viz.renderSVGElement(dot) + } catch (e) { + console.error(e) + } +} diff --git a/console/client/src/features/modules/format-svg.ts b/console/client/src/features/modules/format-svg.ts new file mode 100644 index 0000000000..d1d5f06ba9 --- /dev/null +++ b/console/client/src/features/modules/format-svg.ts @@ -0,0 +1,75 @@ +import { callIcon, moduleVerbCls, callIconID } from './constants' +export const formatSVG = (svg: SVGSVGElement): SVGSVGElement => { + svg.insertAdjacentHTML('afterbegin', callIcon) + + for (const $a of svg.querySelectorAll('a')) { + const $g = $a.parentNode! as SVGSVGElement + + const $docFrag = document.createDocumentFragment() + while ($a.firstChild) { + const $child = $a.firstChild + $docFrag.appendChild($child) + } + + $g.replaceChild($docFrag, $a) + + $g.id = $g.id.replace(/^a_/, '') + } + + for (const $el of svg.querySelectorAll('title')) { + $el.remove() + } + + const edgesSources = new Set() + for (const $edge of svg.querySelectorAll('.edge')) { + const [from, to] = $edge.id.split('=>') + $edge.removeAttribute('id') + $edge.setAttribute('data-from', from) + $edge.setAttribute('data-to', to) + edgesSources.add(from) + } + + for (const $el of svg.querySelectorAll('[id*=\\:\\:]')) { + const [tag, id] = $el.id.split('::') + $el.id = id + $el.classList.add(tag) + } + + for (const $path of svg.querySelectorAll('g.edge path')) { + const $newPath = $path.cloneNode() as HTMLElement + $newPath.classList.add('hover-path') + $newPath.removeAttribute('stroke-dasharray') + $path.parentNode?.appendChild($newPath) + } + + for (const $verb of svg.querySelectorAll(`.${moduleVerbCls}`)) { + const texts = $verb.querySelectorAll('text') + texts[0].classList.add('verb-name') + + // Tag verb as a call source + if (edgesSources.has($verb.id)) $verb.classList.add('call-source') + + // Replace icon + const length = texts.length + for (let i = 1; i < length; ++i) { + const str = texts[i].innerHTML + if (str === '{R}') { + const $iconPlaceholder = texts[i] + const height = 22 + const width = 22 + const $useIcon = document.createElementNS('http://www.w3.org/2000/svg', 'use') + $useIcon.setAttributeNS('http://www.w3.org/1999/xlink', 'href', `#${callIconID}`) + $useIcon.setAttribute('width', `${width}px`) + $useIcon.setAttribute('height', `${height}px`) + + //FIXME: remove hardcoded offset + const y = parseInt($iconPlaceholder.getAttribute('y')!) - 15 + $useIcon.setAttribute('x', $iconPlaceholder.getAttribute('x')!) + $useIcon.setAttribute('y', y.toString()) + $verb.replaceChild($useIcon, $iconPlaceholder) + continue + } + } + } + return svg +} diff --git a/console/client/src/features/modules/generate-dot.ts b/console/client/src/features/modules/generate-dot.ts new file mode 100644 index 0000000000..e72c147170 --- /dev/null +++ b/console/client/src/features/modules/generate-dot.ts @@ -0,0 +1,91 @@ +import { GetModulesResponse, Module } from '../../protos/xyz/block/ftl/v1/console/console_pb' +import { moduleTitleCls, moduleVerbCls } from './constants' +const HtmlId = (id: string) => `HREF="remove_me_url" ID="${id}"` + +const generateRow = ({ + moduleName, + verbName = '', + hasCalls, +}: { + moduleName: string + verbName?: string + hasCalls: boolean +}): string => { + const callsIcon = hasCalls ? '{R}' : '' + return ` + + + + + + +
    ${verbName} ${' '.repeat(4)}${callsIcon}
    + +` +} + +const generateModuleContent = (module: Module): { node: string; edges: string } => { + let edges = '' + const moduleName = module.name + const node = ` + ${moduleName} [ + id=${moduleName} + label=< + + + + + ${module.verbs + .map(({ verb }) => { + let hasCalls = false + verb?.metadata.forEach((metadataEntry) => { + if (metadataEntry?.value?.case === 'calls') { + const calls = metadataEntry.value.value.calls + if (!hasCalls) { + hasCalls = Boolean(calls.length) + } + calls.forEach((call) => { + if (call.module) { + edges += `\n"${moduleName}":"${verb.name}" -> "${call.module}":"${call.name}"[ + id = "${moduleName}.${verb.name}=>${call.module}.${call.name}" + ]` + } + }) + } + }) + return generateRow({ moduleName, verbName: verb?.name, hasCalls }) + }) + .join('\n')} +
    ${' '.repeat( + 4, + )}${moduleName}${' '.repeat(4)}
    + > + ]` + return { edges, node } +} + +export const generateDot = ({ modules }: GetModulesResponse): string => { + let nodes = '' + let allEdges = '' + modules.reverse().forEach((module) => { + const { node, edges } = generateModuleContent(module) + nodes += node + allEdges += edges + }) + return ` + digraph erd { + graph [ + rankdir = "LR" + ]; + node [ + fontsize = "16" + fontname = "helvetica" + shape = "plaintext" + ]; + edge [ + ]; + ranksep = 2.0 + ${nodes} + ${allEdges} + }` +}