From 4e8eaa969fca939d71794391f2a2b1e7579ab9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20S=C3=BC=C3=9F?= Date: Tue, 23 Jan 2024 11:56:15 +0100 Subject: [PATCH] feat: add visualization support (#184) --- package-lock.json | 248 +++++++++++++ package.json | 5 + src/App.tsx | 30 +- src/components/ComponentGraph/index.tsx | 445 ++++++++++++++++++++++++ src/components/GraphPanel/index.tsx | 45 +++ src/lib/river.test.ts | 82 +++-- src/lib/river.ts | 23 ++ 7 files changed, 853 insertions(+), 25 deletions(-) create mode 100644 src/components/ComponentGraph/index.tsx create mode 100644 src/components/GraphPanel/index.tsx diff --git a/package-lock.json b/package-lock.json index 72417fa..5eb7a23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/d3": "^7.4.3", + "@types/d3-zoom": "^3.0.8", "@types/jest": "^27.5.2", "@types/node": "^16.18.34", "@types/react": "^18.2.8", @@ -37,6 +39,9 @@ "case-sensitive-paths-webpack-plugin": "^2.4.0", "css-loader": "^6.5.1", "css-minimizer-webpack-plugin": "^3.2.0", + "d3": "^7.8.5", + "d3-dag": "^0.11.5", + "d3-zoom": "^3.0.0", "dotenv": "^10.0.0", "dotenv-expand": "^5.1.0", "eslint": "^8.3.0", @@ -6006,11 +6011,142 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.9.tgz", + "integrity": "sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.6.tgz", + "integrity": "sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -6019,6 +6155,84 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.2.tgz", + "integrity": "sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", + "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz", + "integrity": "sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", @@ -6064,6 +6278,11 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.13", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", + "integrity": "sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -8820,6 +9039,17 @@ "node": ">=12" } }, + "node_modules/d3-dag": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/d3-dag/-/d3-dag-0.11.5.tgz", + "integrity": "sha512-sNHvYqjzDlvV2fyEkoOCSuLs2GeWliIg7pJcAiKXgtUSxl0kIX0C2q1J8JzzA9CQWptKxYtzxFCXiKptTW8qsQ==", + "dependencies": { + "d3-array": "^3.1.6", + "fastpriorityqueue": "0.7.2", + "javascript-lp-solver": "0.4.24", + "quadprog": "^1.6.1" + } + }, "node_modules/d3-delaunay": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", @@ -10668,6 +10898,11 @@ "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==" }, + "node_modules/fastpriorityqueue": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.2.tgz", + "integrity": "sha512-5DtIKh6vtOmEGkYdEPNNb+mxeYCnBiKbK3s4gq52l6cX8I5QaTDWWw0Wx/iYo80fVOblSycHu1/iJeqeNxG8Jw==" + }, "node_modules/fastq": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", @@ -12792,6 +13027,11 @@ "node": ">=8" } }, + "node_modules/javascript-lp-solver": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/javascript-lp-solver/-/javascript-lp-solver-0.4.24.tgz", + "integrity": "sha512-5edoDKnMrt/u3M6GnZKDDIPxOyFOg+WrwDv8mjNiMC2DePhy2H9/FFQgf4ggywaXT1utvkxusJcjQUER72cZmA==" + }, "node_modules/jest": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", @@ -17478,6 +17718,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quadprog": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/quadprog/-/quadprog-1.6.1.tgz", + "integrity": "sha512-fN5Jkcjlln/b3pJkseDKREf89JkKIyu6cKIVXisgL6ocKPQ0yTp9n6NZUAq3otEPPw78WZMG9K0o9WsfKyMWJw==", + "engines": { + "node": ">=8.x" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", diff --git a/package.json b/package.json index eccef5f..d35d273 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/d3": "^7.4.3", + "@types/d3-zoom": "^3.0.8", "@types/jest": "^27.5.2", "@types/node": "^16.18.34", "@types/react": "^18.2.8", @@ -32,6 +34,9 @@ "case-sensitive-paths-webpack-plugin": "^2.4.0", "css-loader": "^6.5.1", "css-minimizer-webpack-plugin": "^3.2.0", + "d3": "^7.8.5", + "d3-dag": "^0.11.5", + "d3-zoom": "^3.0.0", "dotenv": "^10.0.0", "dotenv-expand": "^5.1.0", "eslint": "^8.3.0", diff --git a/src/App.tsx b/src/App.tsx index 267b288..308eea0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,6 @@ import { Icon, Tooltip, VerticalGroup, - HorizontalGroup, Badge, } from "@grafana/ui"; import Header from "./components/Header"; @@ -21,6 +20,7 @@ import { useModelContext } from "./state"; import InstallationInstructions from "./components/InstallationInstructions"; import ConfigurationWizard from "./components/ConfigurationWizard"; import Converter from "./components/Converter"; +import GraphPanel from "./components/GraphPanel"; function App() { const styles = useStyles(getStyles); @@ -33,6 +33,9 @@ function App() { const [converterOpen, setConverterOpen] = useState(false); const openConverter = () => setConverterOpen(true); const closeConverter = () => setConverterOpen(false); + const [graphOpen, setGraphOpen] = useState(false); + const openGraph = () => setGraphOpen(true); + const closeGraph = () => setGraphOpen(false); const { model } = useModelContext(); const [copied, setCopied] = useState(false); @@ -69,7 +72,7 @@ function App() { you use the configuration wizard or get started with an example configuration, based on your usecase.

- +
@@ -90,7 +93,15 @@ function App() { > View Flow Docs - +
+ +
@@ -149,6 +160,14 @@ function App() { > + + + ); } @@ -215,6 +234,11 @@ const getStyles = (theme: GrafanaTheme2) => { wizardModal: css` min-width: 50%; `, + buttonBar: css` + display: flex; + gap: 0.5rem; + width: 100%; + `, }; }; diff --git a/src/components/ComponentGraph/index.tsx b/src/components/ComponentGraph/index.tsx new file mode 100644 index 0000000..1fdd93a --- /dev/null +++ b/src/components/ComponentGraph/index.tsx @@ -0,0 +1,445 @@ +import { FC, useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { + coordSimplex, + dagStratify, + decrossTwoLayer, + layeringCoffmanGraham, + NodeSizeAccessor, + sugiyama, +} from "d3-dag"; +import { Point } from "d3-dag/dist/dag"; +import { IdOperator, ParentIdsOperator } from "d3-dag/dist/dag/create"; +import * as d3Zoom from "d3-zoom"; +import { useTheme } from "../../theme"; + +export interface ComponentInfo { + /** The moduleID that the component is defined in. moduleID may be the empty string. */ + moduleID: string; + + /** The id of the component uniquely identifies the component within a module. */ + localID: string; + + /** + * The name of the component is the name of the block used to instantiate + * the component. For example, the component ID + * prometheus.remote_write.default would have a name of + * "prometheus.remote_write". + */ + name: string; + + /** + * Label is an optional label for a component. Not all components may have + * labels. + * + * For example, the prometheus.remote_write.default component would have a + * label of "default". + */ + label?: string; + + /** + * IDs of components which are referencing this component. + */ + referencedBy: string[]; + + /** + * IDs of components which this component is referencing. + */ + referencesTo: string[]; +} + +let canvas: HTMLCanvasElement | undefined; + +/** + * calcTextWidth calculates the width of text if it were to be rendered on + * screen. + * + * font should be a font specifier like "bold 16pt arial" + */ +function calcTextWidth(text: string, font: string): number | null { + // Adapted from https://stackoverflow.com/a/21015393 + + // Lazy-load the canvas if it hasn't been created yet. + if (canvas === undefined) { + canvas = document.createElement("canvas"); + } + + const context = canvas.getContext("2d"); + if (context == null) { + return null; + } + context.font = font; + return context.measureText(text).width; +} + +/** + * intersectsBox reports whether a point intersects a box. + */ +function intersectsBox(point: Point, box: Box): boolean { + return ( + point.x >= box.x && // after starting X + point.y >= box.y && // after starting Y + point.x <= box.x + box.w && // before ending X + point.y <= box.y + box.h // before ending Y + ); +} + +interface Line { + start: Point; + end: Point; +} + +/* + * boxIntersectionPoint returns the point where line intersects box. + */ +function boxIntersectionPoint(line: Line, box: Box): Point { + const boxTop: Line = { + start: { x: box.x, y: box.y }, + end: { x: box.x + box.w, y: box.y }, + }; + const topIntersectionPoint = lineIntersectionPoint(line, boxTop); + if (topIntersectionPoint !== undefined) { + return topIntersectionPoint; + } + + const boxRight: Line = { + start: { x: box.x + box.w, y: box.y }, + end: { x: box.x + box.w, y: box.y + box.h }, + }; + const rightIntersectionPoint = lineIntersectionPoint(line, boxRight); + if (rightIntersectionPoint !== undefined) { + return rightIntersectionPoint; + } + + const boxBottom: Line = { + start: { x: box.x, y: box.y + box.h }, + end: { x: box.x + box.w, y: box.y + box.h }, + }; + const bottomIntersectionPoint = lineIntersectionPoint(line, boxBottom); + if (bottomIntersectionPoint !== undefined) { + return bottomIntersectionPoint; + } + + const boxLeft: Line = { + start: { x: box.x, y: box.y }, + end: { x: box.x, y: box.y + box.h }, + }; + const leftInsersectionPoint = lineIntersectionPoint(line, boxLeft); + if (leftInsersectionPoint !== undefined) { + return leftInsersectionPoint; + } + + // No intersection; just return the last point of the line. + return line.end; +} + +/* + * lineIntersectionPoint returns the point where l1 and l2 intersect. + * + * Returns undefined if the lines do not intersect. + */ +function lineIntersectionPoint(l1: Line, l2: Line): Point | undefined { + // https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line_segment + + // l1 = (x1, y1) -> (x2, y2) + // l2 = (x3, y3) -> (x4, y4) + const [x1, y1] = [l1.start.x, l1.start.y]; + const [x2, y2] = [l1.end.x, l1.end.y]; + const [x3, y3] = [l2.start.x, l2.start.y]; + const [x4, y4] = [l2.end.x, l2.end.y]; + + const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if (denominator === 0) { + return undefined; + } + + const t_numerator = (x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4); + const u_numerator = (x1 - x3) * (y1 - y2) - (y1 - y3) * (x1 - x2); + + // Only t is used for calculating the point, but both t and u must be defined + // to ensure the intersection exists. + const [t, u] = [t_numerator / denominator, u_numerator / denominator]; + + // There is an intersection if and only if 0 <= t <= 1 and 0 <= u <= 1 + if (0 <= t && t <= 1 && 0 <= u && u <= 1) { + return { + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1), + }; + } + + return undefined; +} + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +export interface ComponentGraphProps { + components: ComponentInfo[]; +} + +/** + * ComponentGraph renders an SVG with relationships between defined components. + * The components prop must be a non-empty array. + */ +export const ComponentGraph: FC = (props) => { + const theme = useTheme(); + + const svgRef = useRef(null); + + useEffect(() => { + // NOTE(rfratto): The default units of svg are in pixels. + + const [nodeWidth, nodeHeight] = [150, 75]; + const nodeMargin = 25; + const nodePadding = 5; + + const widthCache: Record = { + foo: 5, + }; + + const builder = dagStratify() + .id>((n) => n.localID) + .parentIds>((n) => n.referencedBy); + const dag = builder(props.components); + + // Our graph layout is optimized for graphs of 50 components or more. The + // decross method is where most of the layout time is spent; decrossOpt is + // far too slow. + // + // We also use Coffman Graham for layering, which constrains the final + // width of the graph as much as possible. + const layout = sugiyama() + .layering(layeringCoffmanGraham()) + .decross(decrossTwoLayer()) + .coord(coordSimplex()) + .nodeSize>((n) => { + // nodeSize is the full amount of space you want the node to take up. + // + // It can be considered similar to the box model: margin and padding should + // be added to the size. + + // n will be undefined for synthetic nodes in a layer. These synthetic + // nodes can be given sizes, but we keep them at [0, 0] to minimize the + // total width of the graph. + if (n === undefined) { + return [0, 0]; + } + + // Calculate how much width the text needs to be displayed. + let width = nodeWidth; + + const displayFont = "bold 13px 'Roboto', sans-serif"; + + const nameWidth = calcTextWidth(n.data.name, displayFont); + if (nameWidth != null && nameWidth > width) { + width = nameWidth; + } + + const labelWidth = calcTextWidth(n.data.label || "", displayFont); + if (labelWidth != null && labelWidth > width) { + width = labelWidth; + } + + // Cache the width so it can be used while plotting the SVG. + widthCache[n.data.localID] = width; + + return [ + width + nodeMargin + nodePadding * 2, + nodeHeight + nodeMargin + nodePadding * 2, + ]; + }); + const { width, height } = layout(dag); + + // svgRef.current needs to be cast to an Element for type checks to work. + // SVGSVGElement doesn't extend element and prevents zoom from + // typechecking. + // + // Everything still seems to work even with the type cast. + const svgSelection = d3.select(svgRef.current as Element); + svgSelection.selectAll("*").remove(); // Clear svg content before adding new elements + svgSelection.attr("viewBox", [0, 0, width, height].join(" ")); + + const svgWrapper = svgSelection.append("g"); + + // TODO(rfratto): determine a reasonable zoom scale extent based on size of + // layout rather than hard coding 0.1x to 10x. + // + // As it is now, you can zoom in way too close on really small graphs. + const zoom = d3Zoom + .zoom() + .scaleExtent([0.1, 10]) + .on("zoom", (e) => { + svgWrapper.attr("transform", e.transform); + }); + + svgSelection.call(zoom).call(zoom.transform, d3Zoom.zoomIdentity); + + // Add a marker element so we can draw an arrow pointing between nodes. + svgWrapper + .append("defs") + .append("marker") + .attr("id", "arrow") + .attr("viewBox", [0, 0, 20, 20]) + .attr("refX", 17) + .attr("refY", 10) + .attr("markerWidth", 5) + .attr("markerHeight", 5) + .attr("orient", "auto-start-reverse") + .append("path") + .attr( + "d", + // Draw an arrow shape + d3.line()([ + [0, 0], // Bottom left of arrow + [0, 20], // Top left of arrow + [20, 10], // Middle point of arrow + ]), + ) + .attr("fill", "#c8c9ca"); + + const line = d3 + .line() + .curve(d3.curveCatmullRom) + .x((d) => d.x) + .y((d) => d.y); + + // Plot edges + svgWrapper + .append("g") + .selectAll("path") + .data(dag.links()) + .enter() + .append("path") + .attr("marker-end", "url(#arrow)") + .attr("d", (node) => { + // We want to draw arrows between boxes, but by default the arrows are + // obscured; d3-dag points lines to the middle of a box which is hidden + // by the rectangle. + // + // To fix this, we do the following: + // + // 1. Retrieve the set of generated points for d3-dag + // 2. Remove all points after the first point which intersects the box + // 3. Move the final point to the coordinates where it intersects the + // box + // 4. The line will now stop at the box edge as expected. + + const nodeBox: Box = { + x: + (node.target.x || 0) - + widthCache[node.target.data.localID] / 2 - + nodePadding, + y: (node.target.y || 0) - nodeHeight / 2 - nodePadding, + w: widthCache[node.target.data.localID] + nodePadding * 2, + h: nodeHeight + nodePadding * 2, + }; + + const idx = node.points.findIndex((p) => { + return intersectsBox(p, nodeBox); + }); + if (idx === -1) { + // It shouldn't be possible for this to happen; we know that the + // final point always goes to the center of the target box so there + // should always be an intersection. + throw new Error( + "could not find point of intersection with target node", + ); + } + const trimmedPoints = node.points.slice(0, idx + 1); + + const intersectingLine = { + start: trimmedPoints[trimmedPoints.length - 2], + end: trimmedPoints[trimmedPoints.length - 1], + }; + const fixedPoint = boxIntersectionPoint(intersectingLine, nodeBox); + trimmedPoints[trimmedPoints.length - 1] = fixedPoint; + + return line(trimmedPoints); + }) + .attr("fill", "none") + .attr("stroke-width", "2px") + .attr("stroke", "#c8c9ca") + .append("title") // Append tooltip to edge + .text((n) => { + return `${n.source.data.localID} to ${n.target.data.localID}`; + }); + + // Select nodes + const nodes = svgWrapper + .append("g") + .selectAll("g") + .data(dag.descendants()) + .enter() + .append("g") + .attr("transform", (node) => { + // node.x, node.y refer to the absolute center of the box. + // + // We translate the group to the top-left corner to make it easier to + // position all the elements. Top left corner should account for + // padding space. + const x = + (node.x || 0) - widthCache[node.data.localID] / 2 - nodePadding; + const y = (node.y || 0) - nodeHeight / 2 - nodePadding; + return `translate(${x}, ${y})`; + }); + + // const linkedNodes = nodes.append("a").attr("href", (n) => `#`); + + // Plot nodes + nodes + .append("rect") + .attr("fill", theme.colors.background.secondary) + .attr("rx", 3) + .attr("height", nodeHeight + nodePadding * 2) + .attr("width", (node) => { + return widthCache[node.data.localID] + nodePadding * 2; + }) + .attr("stroke-width", "1") + .attr("stroke", theme.colors.border.strong); + + // Create a group for node content which is anchored inside of the padding + // area. + const nodeContent = nodes + .append("g") + .attr("transform", `translate(${nodePadding}, ${nodePadding})`); + + // Add component name text + nodeContent + .append("text") + .text((d) => d.data.name) + .attr("y", 13 /* font size */ + 2 /* margin from previous text line */) + .attr("font-size", "13") + .attr("font-weight", "bold") + .attr("font-family", '"Roboto", sans-serif') + .attr("text-anchor", "start") + .attr("alignment-baseline", "hanging") + .attr("fill", theme.colors.text.primary); + + // Add component label text + nodeContent + .append("text") + .text((d) => d.data.label || "asdf") + .attr( + "y", + 2 * 13 /* font size */ + 2 /* margin from previous text line */, + ) + .attr("font-size", "13") + .attr("font-weight", "normal") + .attr("font-family", '"Roboto", sans-serif') + .attr("text-anchor", "start") + .attr("alignment-baseline", "hanging") + .attr("fill", theme.colors.text.secondary); + }); + + return ( + + ); +}; diff --git a/src/components/GraphPanel/index.tsx b/src/components/GraphPanel/index.tsx new file mode 100644 index 0000000..9abcae1 --- /dev/null +++ b/src/components/GraphPanel/index.tsx @@ -0,0 +1,45 @@ +import { useComponentContext } from "../../state"; +import { useEffect, useState } from "react"; +import { ComponentGraph, ComponentInfo } from "../ComponentGraph"; +import { Alert } from "@grafana/ui"; +import { collectReferences } from "../../lib/river"; + +const GraphPanel = () => { + const [infos, setInfo] = useState([]); + + const { components } = useComponentContext(); + useEffect(() => { + let i = components.map((c) => { + const references = collectReferences(c.block); + return { + moduleID: "", + localID: `${c.block.name}.${c.block.label}`, + name: `${c.block.name}`, + label: c.block.label ?? undefined, + referencedBy: [] as string[], + referencesTo: references.map((s) => + s + .split(".") + .slice(0, s.startsWith("module") ? -2 : -1) + .join("."), + ), + }; + }); + i = i.map((c) => { + c.referencedBy = i + .filter((o) => o.referencesTo.includes(c.localID)) + .map((o) => o.localID); + return c; + }); + setInfo(i); + }, [components, setInfo]); + + return ( + <> + {infos.length < 1 && } + {infos.length > 1 && } + + ); +}; + +export default GraphPanel; diff --git a/src/lib/river.test.ts b/src/lib/river.test.ts index d84080e..dd06c97 100644 --- a/src/lib/river.test.ts +++ b/src/lib/river.test.ts @@ -1,4 +1,11 @@ -import { toArgument, UnmarshalBlock, Attribute, Block, toBlock } from "./river"; +import { + toArgument, + UnmarshalBlock, + Attribute, + Block, + toBlock, + collectReferences, +} from "./river"; import Parser from "web-tree-sitter"; import { KnownComponents } from "./components"; @@ -14,11 +21,42 @@ describe("argument parsing", () => { expect( toArgument("name", { foo: "bar", - }) + }), ).toEqual(new Block("name", null, [new Attribute("foo", "bar")])); }); }); +describe("collecting references", () => { + test("collect simple references", () => { + expect( + collectReferences( + new Block("otelcol.exporter.loki", "to_loki", [ + new Attribute("forward_to", [ + { + "-reference": "module.git.grafana_cloud.exports.metrics_receiver", + }, + ]), + ]), + ), + ).toEqual(["module.git.grafana_cloud.exports.metrics_receiver"]); + }); + test("collect block references", () => { + expect( + collectReferences( + new Block("otelcol.receiver.otlp", "default", [ + new Block("output", null, [ + new Attribute("metrics", [ + { + "-reference": "otelcol.exporter.prometheus.to_prometheus.input", + }, + ]), + ]), + ]), + ), + ).toEqual(["otelcol.exporter.prometheus.to_prometheus.input"]); + }); +}); + describe("marshall/unmarshal", () => { let parser: Parser; beforeAll(async () => { @@ -65,7 +103,7 @@ describe("marshall/unmarshal", () => { new Block("endpoint", null, [ new Attribute( "url", - "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom" + "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom", ), ]), ]); @@ -119,7 +157,7 @@ describe("marshall/unmarshal", () => { expect(out).toEqual( new Block("prometheus.exporter.redis", "my_redis", [ new Attribute("redis_addr", "localhost:6379"), - ]) + ]), ); }); @@ -135,10 +173,10 @@ describe("marshall/unmarshal", () => { new Block("endpoint", null, [ new Attribute( "url", - "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom" + "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom", ), ]), - ]) + ]), ); }); test("unmarshal references", () => { @@ -151,7 +189,7 @@ targets = [prometheus.exporter.redis.target] new Attribute("targets", [ { "-reference": "prometheus.exporter.redis.target" }, ]), - ]) + ]), ); }); test("unmarshal mixed array", () => { @@ -174,7 +212,7 @@ targets = [prometheus.exporter.redis.target] new Attribute("forward_to", [ { "-reference": "prometheus.remote_write.default.receiver" }, ]), - ]) + ]), ); }); test("to form with spec", () => { @@ -200,7 +238,7 @@ targets = [prometheus.exporter.redis.target] "discovery.ec2", fv, "aws", - KnownComponents["discovery.ec2"] + KnownComponents["discovery.ec2"], ); expect(out).toEqual( new Block("discovery.ec2", "aws", [ @@ -212,7 +250,7 @@ targets = [prometheus.exporter.redis.target] new Attribute("name", "bar"), new Attribute("values", ["b", "c"]), ]), - ]) + ]), ); }); test("omit default values", () => { @@ -226,12 +264,12 @@ targets = [prometheus.exporter.redis.target] "otelcol.receiver.otlp", fv, "default", - KnownComponents["otelcol.receiver.otlp"] + KnownComponents["otelcol.receiver.otlp"], ); expect(out).toEqual( new Block("otelcol.receiver.otlp", "default", [ new Block("grpc", null, [new Attribute("endpoint", "0.0.0.0:4137")]), - ]) + ]), ); }); test("omit nested default values", () => { @@ -247,23 +285,23 @@ targets = [prometheus.exporter.redis.target] "prometheus.remote_write", fv, "default", - KnownComponents["prometheus.remote_write"] + KnownComponents["prometheus.remote_write"], ); expect(out).toEqual( new Block("prometheus.remote_write", "default", [ new Block("endpoint", null, [ new Attribute( "url", - "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom" + "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom", ), ]), - ]) + ]), ); }); test("fill default values", () => { const block = new Block("otelcol.exporter.prometheus", "default", []); const out = block.formValues( - KnownComponents["otelcol.exporter.prometheus"] + KnownComponents["otelcol.exporter.prometheus"], ); expect(out).toEqual({ include_scope_info: true, @@ -278,12 +316,12 @@ targets = [prometheus.exporter.redis.target] "otelcol.receiver.otlp", fv, "default", - KnownComponents["otelcol.receiver.otlp"] + KnownComponents["otelcol.receiver.otlp"], ); expect(out).toEqual( new Block("otelcol.receiver.otlp", "default", [ new Block("grpc", null, []), - ]) + ]), ); }); test("unmarshal blocks with dot in name", () => { @@ -304,7 +342,7 @@ targets = [prometheus.exporter.redis.target] new Attribute("delta", false), ]), ]), - ]) + ]), ); }); test("preserve dots in form value", () => { @@ -317,7 +355,7 @@ targets = [prometheus.exporter.redis.target] new Attribute("delta", false), ]), ]), - ]).formValues(KnownComponents["pyroscope.scrape"])["profiling_config"] + ]).formValues(KnownComponents["pyroscope.scrape"])["profiling_config"], ).toEqual({ path_prefix: "/app", "profile.memory": { @@ -393,7 +431,7 @@ targets = [prometheus.exporter.redis.target] new Block("endpoint", null, [ new Attribute( "url", - "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom" + "https://prometheus-prod-24-prod-eu-west-2.grafana.net/api/prom", ), new Block("basic_auth", null, [ new Attribute("username", "foo"), @@ -413,7 +451,7 @@ targets = [prometheus.exporter.redis.target] expect(parsed).toEqual(tc.parsed); expect(parsed.marshal()).toEqual(tc.raw); let reparsed = UnmarshalBlock( - parser.parse(parsed.marshal()).rootNode.namedChild(0)! + parser.parse(parsed.marshal()).rootNode.namedChild(0)!, ); expect(reparsed).toEqual(tc.parsed); } diff --git a/src/lib/river.ts b/src/lib/river.ts index 93528cd..156bc7e 100644 --- a/src/lib/river.ts +++ b/src/lib/river.ts @@ -256,3 +256,26 @@ export function toBlock( if (args.length === 0 && !(spec as BlockType)?.allowEmpty) return null; return new Block(k, label, args); } + +function extractRefs(...v: any[]): string[] { + const out: string[] = []; + for (const e of v) { + if (typeof e === "object" && e["-reference"]) out.push(e["-reference"]); + } + return out; +} + +export function collectReferences(component: Block): string[] { + const out: string[] = []; + for (const attr of component.attributes) { + if (attr instanceof Attribute) { + if (typeof attr.value === "object") { + if (Array.isArray(attr.value)) out.push(...extractRefs(...attr.value)); + else out.push(...extractRefs(attr.value)); + } + } else { + out.push(...collectReferences(attr as Block)); + } + } + return out; +}