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;
+}