diff --git a/README.md b/README.md index a253d33..a134fae 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The dashboard makes it possible to: - NextJS - ReactJS - Tailwind CSS +- [React Flow](https://reactflow.dev/) for the Control Center - Auth0 - Nginx - Docker diff --git a/docker/default.conf b/docker/default.conf index 4be2fca..4549f93 100644 --- a/docker/default.conf +++ b/docker/default.conf @@ -7,6 +7,10 @@ server { root /usr/share/nginx/html; default_type application/wasm; } + location = /ironrdp-pkg/ironrdp_web_bg.wasm { + root /usr/share/nginx/html; + default_type application/wasm; + } location / { try_files $uri $uri.html $uri/ =404; diff --git a/package-lock.json b/package-lock.json index 0f58b98..89fc02e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0", "dependencies": { "@axa-fr/react-oidc": "^7.22.18", + "@dagrejs/dagre": "^1.1.5", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", @@ -30,6 +31,7 @@ "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.10.7", "@types/crypto-js": "^4.2.2", + "@types/d3": "^7.4.3", "@types/lodash": "^4.14.200", "@types/node": "20.10.6", "@types/react": "^18", @@ -37,6 +39,7 @@ "@types/react-window": "^1.8.8", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.8.4", "autoprefixer": "^10", "chart.js": "^4.4.8", "chroma-js": "^3.1.2", @@ -44,8 +47,10 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "crypto-js": "^4.2.0", + "d3": "^7.9.0", "date-fns": "^2.30.0", "dayjs": "^1.11.10", + "elkjs": "^0.10.0", "eslint": "^8", "eslint-config-prettier": "^9.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", @@ -55,7 +60,7 @@ "ip-cidr": "^3.1.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", - "lucide-react": "^0.481.0", + "lucide-react": "^0.539.0", "next": "^14.2.28", "next-themes": "^0.2.1", "punycode": "^2.3.1", @@ -201,6 +206,24 @@ "ms": "^2.1.1" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.5.tgz", + "integrity": "sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -2588,6 +2611,265 @@ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==" }, + "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==", + "license": "MIT", + "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.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/js-cookie": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", @@ -2911,6 +3193,38 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "license": "MIT" }, + "node_modules/@xyflow/react": { + "version": "12.8.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz", + "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.70", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", + "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -3724,6 +4038,12 @@ "url": "https://joebell.co.uk" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", @@ -4209,6 +4529,416 @@ "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4305,6 +5035,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4400,6 +5139,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.592.tgz", "integrity": "sha512-D3NOkROIlF+d5ixnz7pAf3Lu/AuWpd6AYgI9O67GQXMXTcCP1gJQRotOq35eQy5Sb4hez33XH1YdTtILA7Udww==" }, + "node_modules/elkjs": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.2.tgz", + "integrity": "sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -5761,6 +6506,18 @@ "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5860,6 +6617,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -6644,9 +7410,9 @@ "license": "ISC" }, "node_modules/lucide-react": { - "version": "0.481.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.481.0.tgz", - "integrity": "sha512-NrvUDNFwgLIvHiwTEq9boa5Kiz1KdUT8RJ+wmNijwxdn9U737Fw42c43sRxJTMqhL+ySHpGRVCWpwiF+abrEjw==", + "version": "0.539.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz", + "integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -7836,6 +8602,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7858,6 +8630,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -7924,7 +8702,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/scheduler": { @@ -8905,11 +9682,12 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util-deprecate": { @@ -9101,6 +9879,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index ef1015d..046f946 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@axa-fr/react-oidc": "^7.22.18", + "@dagrejs/dagre": "^1.1.5", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", @@ -35,6 +36,7 @@ "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.10.7", "@types/crypto-js": "^4.2.2", + "@types/d3": "^7.4.3", "@types/lodash": "^4.14.200", "@types/node": "20.10.6", "@types/react": "^18", @@ -42,6 +44,7 @@ "@types/react-window": "^1.8.8", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.8.4", "autoprefixer": "^10", "chart.js": "^4.4.8", "chroma-js": "^3.1.2", @@ -49,8 +52,10 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "crypto-js": "^4.2.0", + "d3": "^7.9.0", "date-fns": "^2.30.0", "dayjs": "^1.11.10", + "elkjs": "^0.10.0", "eslint": "^8", "eslint-config-prettier": "^9.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", @@ -60,7 +65,7 @@ "ip-cidr": "^3.1.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", - "lucide-react": "^0.481.0", + "lucide-react": "^0.539.0", "next": "^14.2.28", "next-themes": "^0.2.1", "punycode": "^2.3.1", diff --git a/src/app/(dashboard)/control-center/layout.tsx b/src/app/(dashboard)/control-center/layout.tsx new file mode 100644 index 0000000..54641da --- /dev/null +++ b/src/app/(dashboard)/control-center/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Control Center - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/control-center/page.tsx b/src/app/(dashboard)/control-center/page.tsx new file mode 100644 index 0000000..4777691 --- /dev/null +++ b/src/app/(dashboard)/control-center/page.tsx @@ -0,0 +1,1290 @@ +"use client"; + +import "@xyflow/react/dist/style.css"; +import Button from "@components/Button"; +import { + SelectDropdown, + SelectOption, +} from "@components/select/SelectDropdown"; +import useFetchApi from "@utils/api"; +import { + Background, + Edge, + EdgeTypes, + Node, + NodeTypes, + ReactFlow, + ReactFlowProvider, + useReactFlow, +} from "@xyflow/react"; +import { forEach, orderBy, sortBy } from "lodash"; +import { + ArrowLeftIcon, + ExternalLinkIcon, + LayoutGridIcon, + MessageSquareShareIcon, + NetworkIcon, +} from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector"; +import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount"; +import { EDGE_TYPES } from "@/modules/control-center/utils/edges"; +import { + getFirstGroup, + getPolicyProtocolAndPortText, + getResourcePolicyByGroups, +} from "@/modules/control-center/utils/helpers"; +import { + applyD3ForceLayout, + applyD3HierarchicalLayout, + DEFAULT_MAX_ZOOM, + DEFAULT_MIN_ZOOM, +} from "@/modules/control-center/utils/layouts"; +import { NODE_TYPES } from "@/modules/control-center/utils/nodes"; +import PeersProvider from "@/contexts/PeersProvider"; +import PoliciesProvider from "@/contexts/PoliciesProvider"; +import { Group } from "@/interfaces/Group"; +import { Network, NetworkResource } from "@/interfaces/Network"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import { Peer } from "@/interfaces/Peer"; +import { Policy } from "@/interfaces/Policy"; +import PageContainer from "@/layouts/PageContainer"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal"; +import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import SquareIcon from "@components/SquareIcon"; +import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; +import InlineLink from "@components/InlineLink"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useRouter, useSearchParams } from "next/navigation"; +import { SmallBadge } from "@components/ui/SmallBadge"; + +export default function ControlCenter() { + return ( + + + + + + ); +} + +function ControlCenterView() { + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const reactFlow = useReactFlow(); + const [layoutInitialized, setLayoutInitialized] = useState(false); + const [forceLayoutChange, setForceLayoutChange] = useState(false); + const { loggedInUser } = useLoggedInUser(); + + const queryParams = useSearchParams(); + const queryTab = queryParams.get("tab"); + const initialTab = useMemo(() => { + if (queryTab === "peers") return FlowView.PEERS; + if (queryTab === "groups") return FlowView.GROUPS; + if (queryTab === "networks") return FlowView.NETWORKS; + return FlowView.PEERS; + }, [queryTab]); + const [currentView, setCurrentView] = useState(initialTab); + + const { data: policies, isLoading: isPoliciesLoading } = + useFetchApi("/policies"); + const { data: peers, isLoading: isPeersLoading } = + useFetchApi("/peers"); + const { data: networks, isLoading: isNetworksLoading } = + useFetchApi("/networks"); + const { data: networkResources, isLoading: isResourcesLoading } = useFetchApi< + NetworkResource[] + >("/networks/resources"); + const { data: groups, isLoading: isGroupsLoading } = + useFetchApi("/groups"); + + const isLoading = + isPoliciesLoading || + isPeersLoading || + isNetworksLoading || + isResourcesLoading || + isGroupsLoading; + + const [selectedNetwork, setSelectedNetwork] = useState(""); + const [selectedGroup, setSelectedGroup] = useState(""); + const [selectedPeer, setSelectedPeer] = useState(""); + const [selectedPolicy, setSelectedPolicy] = useState(""); + const [selectedDestinationGroup, setSelectedDestinationGroup] = useState(""); + + const [policyModalOpen, setPolicyModalOpen] = useState(false); + + const networkOptions: SelectOption[] = useMemo(() => { + let allNetworks = sortBy( + networks?.map( + (network) => + ({ + value: network.id, + label: network.name, + icon: NetworkIcon, + }) as SelectOption, + ) || [], + "label", + "asc", + ); + allNetworks.unshift({ + value: "", + label: "All Networks", + icon: () => , + } as SelectOption); + return allNetworks; + }, [networks]); + + const onDestinationGroupSelect = useCallback( + (groupId: string) => { + setLayoutInitialized(false); + if (selectedDestinationGroup == groupId) { + setSelectedDestinationGroup(""); + } else { + setSelectedDestinationGroup(groupId); + } + }, + [selectedDestinationGroup], + ); + + const applySingleGroupView = (groupId: string) => { + if (!policies || isLoading) return; + if (!groups || isGroupsLoading) return; + if (!networks || isNetworksLoading) return; + if (!networkResources || isResourcesLoading) return; + + const allNodes: Node[] = []; + const allEdges: Edge[] = []; + + const groupPolicies = sortBy( + policies.filter((policy) => { + const rule = policy.rules?.[0]; + if (!rule) return false; + const sources = rule.sources as Group[]; + return sources?.some((d) => d.id === groupId); + }), + "enabled", + "asc", + ); + + groupPolicies.forEach((policy) => { + const enabled = policy.rules?.[0]?.enabled; + const nodeExists = allNodes.some((n) => n.id === `policy-${policy.id}`); + if (!nodeExists) { + allNodes.push({ + id: `policy-${policy.id}`, + type: "policyNode", + data: { + policy, + }, + position: { x: 0, y: 0 }, + }); + } + + const edgeExists = allEdges.some( + (e) => e.id === `group-policy-${groupId}-${policy.id}`, + ); + if (!edgeExists) { + allEdges.push({ + id: `group-policy-${groupId}-${policy.id}`, + source: `select-group-node`, + target: `policy-${policy.id}`, + type: "in", + data: { enabled, type: "bezier" }, + }); + } + + const destinations = orderBy( + policy.rules?.[0].destinations as Group[], + "name", + "asc", + ); + destinations?.forEach((destination) => { + const destinationNodeId = `group-${destination.id}`; + const destinationNodeExists = allNodes.some( + (n) => n.id === destinationNodeId, + ); + if (!destinationNodeExists) { + allNodes.push({ + id: destinationNodeId, + type: "destinationGroupNode", + data: { + group: destination, + enabled, + }, + position: { x: 0, y: 0 }, + }); + + if (selectedDestinationGroup == destination.id) { + const resources = networkResources.filter((n) => { + const resourceGroupIds = + n.groups?.map((g) => (g as Group)?.id) || []; + return resourceGroupIds.includes(destination.id); + }); + + const destinationPeers = peers?.filter((p) => { + const peerGroupIds = p.groups?.map((g) => g.id) || []; + return peerGroupIds.includes(destination.id); + }); + + destinationPeers?.forEach((peer) => { + const peerNodeId = `peer-${peer.id}`; + const peerNodeExists = allNodes.some((n) => n.id === peerNodeId); + if (!peerNodeExists) { + allNodes.push({ + id: peerNodeId, + type: "peerNode", + data: { peer, enabled }, + position: { x: 0, y: 0 }, + }); + } else { + allNodes.forEach((n) => { + if (n.id === peerNodeId) { + n.data = { + ...n.data, + enabled, + }; + } + }); + } + + const peerEdgeExists = allEdges.some( + (e) => e.id === `group-peer-${destination.id}-${peer.id}`, + ); + if (!peerEdgeExists) { + allEdges.push({ + id: `group-peer-${destination.id}-${peer.id}`, + source: `group-${destination.id}`, + target: peerNodeId, + type: "simple", + }); + } else { + allEdges.forEach((e) => { + if (e.id === `group-peer-${destination.id}-${peer.id}`) { + e.data = { + ...e.data, + enabled, + }; + } + }); + } + }); + + // add resource nodes + resources.forEach((resource) => { + const resourceNodeId = `resource-${resource.id}`; + const resourceNodeExists = allNodes.some( + (n) => n.id === resourceNodeId, + ); + if (!resourceNodeExists) { + allNodes.push({ + id: resourceNodeId, + type: "resourceNode", + data: { resource, enabled }, + position: { x: 0, y: 0 }, + }); + } else { + allNodes.forEach((n) => { + if (n.id === resourceNodeId) { + n.data = { + ...n.data, + enabled, + }; + } + }); + } + + const resourceEdgeExists = allEdges.some( + (e) => + e.id === `group-resource-${destination.id}-${resource.id}`, + ); + if (!resourceEdgeExists) { + allEdges.push({ + id: `group-resource-${destination.id}-${resource.id}`, + source: `group-${destination.id}`, + target: resourceNodeId, + type: "simple", + data: { + enabled, + }, + }); + } else { + allEdges.forEach((e) => { + if ( + e.id === `group-resource-${destination.id}-${resource.id}` + ) { + e.data = { + ...e.data, + enabled, + }; + } + }); + } + }); + } + } else { + allNodes.forEach((n) => { + if (n.id === destinationNodeId) { + n.data = { + ...n.data, + enabled, + }; + } + }); + } + + const destinationEdgeExists = allEdges.some( + (e) => e.id === `policy-group-${policy.id}-${destination.id}`, + ); + if (!destinationEdgeExists) { + allEdges.push({ + id: `policy-group-${policy.id}-${destination.id}`, + source: `policy-${policy.id}`, + target: destinationNodeId, + type: "in", + data: { enabled, type: "bezier" }, + }); + } else { + allEdges.forEach((e) => { + if (e.id === `policy-group-${policy.id}-${destination.id}`) { + e.data = { + ...e.data, + enabled, + }; + } + }); + } + }); + }); + + return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "group", { + policy: { width: 500, spacing: 60 }, + destinationGroup: { width: 1000, spacing: 100 }, + peersAndResources: { width: 1400, spacing: 80 }, + }); + }; + + const applySingleNetworkView = (networkId: string) => { + if (isLoading) return; + if (layoutInitialized) return; + + const allNodes: Node[] = []; + const allEdges: Edge[] = []; + + const network = networks?.find((n) => n.id === networkId); + if (!network) return; + + const networkPolicies = network.policies || []; + + forEach(networkPolicies, (p) => { + const policy = policies?.find((policyItem) => policyItem.id === p); + if (!policy) return; + const enabled = policy.rules?.[0]?.enabled; + + const existsPolicy = allNodes.find( + (node) => node.id === `policy-${policy.id}`, + ); + if (!existsPolicy) { + allNodes.push({ + id: `policy-${policy.id}`, + type: "policyNode", + data: { + policy, + enabled, + }, + position: { x: 0, y: 0 }, + }); + } + + const rule = policy.rules?.[0]; + if (rule) { + const ruleSourceGroups = (rule.sources as Group[]) || []; + + ruleSourceGroups.forEach((group) => { + if (!allNodes.find((node) => node.id === `group-${group.id}`)) { + allNodes.push({ + id: `group-${group.id}`, + type: "groupNode", + data: { + group, + enabled, + onClick: () => forceSingleGroupView(group.id || ""), + }, + position: { x: 0, y: 0 }, + }); + } + + const edgeExists = allEdges.find( + (edge) => edge.id === `group-${group.id}-policy-${policy.id}`, + ); + if (!edgeExists) { + allEdges.push({ + id: `group-${group.id}-policy-${policy.id}`, + source: `group-${group.id}`, + target: `policy-${policy.id}`, + type: "in", + data: { + enabled, + type: "bezier", + }, + }); + } + }); + } + }); + + const resources = network.resources || []; + + resources.forEach((r) => { + const resource = networkResources?.find((n) => n.id === r); + if (!resource) return; + + const existsResource = allNodes.find( + (node) => node.id === `resource-${resource.id}`, + ); + if (!existsResource) { + allNodes.push({ + id: `resource-${resource.id}`, + type: "resourceNode", + data: { + resource, + }, + position: { x: 0, y: 0 }, + }); + } + + const networkResourceGroups = (resource.groups as Group[]) || []; + + let resourcePolicies = getResourcePolicyByGroups( + networkResourceGroups as Group[], + policies ?? [], + ); + + resourcePolicies = resourcePolicies.filter((rp) => + networkPolicies.includes(rp.id || ""), + ); + + resourcePolicies.forEach((policy) => { + const rule = policy.rules?.[0]; + const enabled = policy.enabled; + if (rule) { + const ruleSourceGroups = (rule.sources as Group[]) || []; + const ruleDestinationGroups = (rule.destinations as Group[]) || []; + + ruleDestinationGroups.forEach((group) => { + const resourceGroup = networkResourceGroups.find( + (g) => g.id === group.id, + ); + if (!resourceGroup) return; + + if (!allNodes.find((node) => node.id === `group-${group.id}`)) { + allNodes.push({ + id: `group-${group.id}`, + type: "destinationGroupNode", + data: { + group, + enabled, + hoverable: false, + }, + position: { x: 0, y: 0 }, + }); + } + + // add edge from policy to destination group + const policyDestinationEdgeExists = allEdges.find( + (edge) => edge.id === `policy-${policy.id}-group-${group.id}`, + ); + if (!policyDestinationEdgeExists) { + allEdges.push({ + id: `policy-${policy.id}-group-${group.id}`, + source: `policy-${policy.id}`, + target: `group-${group.id}`, + type: "in", + data: { + enabled, + type: "bezier", + }, + }); + } + + // add edge from destination group to resource + const groupResourceEdgeExists = allEdges.find( + (edge) => edge.id === `group-${group.id}-resource-${resource.id}`, + ); + if (!groupResourceEdgeExists) { + allEdges.push({ + id: `group-${group.id}-resource-${resource.id}`, + source: `group-${group.id}`, + target: `resource-${resource.id}`, + type: "simple", + }); + } + }); + + ruleSourceGroups.forEach((group) => { + // Ensure the group node exists + if (!allNodes.find((node) => node.id === `group-${group.id}`)) { + allNodes.push({ + id: `group-${group.id}`, + type: "groupNode", + data: { + group, + enabled, + }, + position: { x: 0, y: 0 }, + }); + } + + const groupPolicyEdgeExists = allEdges.find( + (edge) => edge.id === `group-${group.id}-policy-${policy.id}`, + ); + if (!groupPolicyEdgeExists) { + allEdges.push({ + id: `group-${group.id}-policy-${policy.id}`, + source: `group-${group.id}`, + target: `policy-${policy.id}`, + type: "in", + data: { + enabled, + type: "bezier", + }, + }); + } + }); + } + }); + }); + + return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "network", { + policy: { width: 500, spacing: 60 }, + destinationGroup: { width: 1000, spacing: 100 }, + peersAndResources: { width: 1400, spacing: 80 }, + }); + }; + + const applyNetworksView = () => { + if (!policies || isLoading) return; + if (!groups || isGroupsLoading) return; + if (!networks || isNetworksLoading) return; + if (!networkResources || isResourcesLoading) return; + + // Skip layout updates if already initialized + if (layoutInitialized) { + return; // Exit early for initialized layouts + } + + const allNodes: Node[] = []; + const allEdges: Edge[] = []; + const hidePolicies = !selectedNetwork; + + // Process networks + networks.forEach((network) => { + allNodes.push({ + id: `network-${network.id}`, + type: "networkNode", + data: { + network, + selectedNetwork, + }, + draggable: true, + position: { x: 0, y: 0 }, + }); + + const networkPolicies = network.policies || []; + if (networkPolicies.length > 0) { + forEach(networkPolicies, (p) => { + const policy = policies.find((policyItem) => policyItem.id === p); + if (policy) { + const enabled = policy.rules?.[0]?.enabled; + + const rule = policy.rules?.[0]; + if (rule) { + const ruleSourceGroups = (rule.sources as Group[]) || []; + + ruleSourceGroups.forEach((group) => { + if (!allNodes.find((node) => node.id === `group-${group.id}`)) { + allNodes.push({ + id: `group-${group.id}`, + type: "groupNode", + data: { + group, + enabled, + onClick: () => forceSingleGroupView(group.id || ""), + }, + position: { x: 0, y: 0 }, + }); + } + + const edge2Exists = allEdges.find( + (edge) => + edge.id === `group-${group.id}-network-${network.id}`, + ); + if (!edge2Exists && hidePolicies) { + const label = getPolicyProtocolAndPortText(policy); + allEdges.push({ + id: `group-${group.id}-network-${network.id}`, + source: `group-${group.id}`, + target: `network-${network.id}`, + type: "floating-straight", + data: { label: label }, + }); + } + }); + } + } + }); + } + }); + + return applyD3ForceLayout(allNodes, allEdges); + }; + + const applyPeerView = (peerId: string) => { + if (!policies || isLoading) return; + if (!groups || isGroupsLoading) return; + if (!networks || isNetworksLoading) return; + if (!networkResources || isResourcesLoading) return; + if (layoutInitialized) return; + + const allNodes: Node[] = []; + const allEdges: Edge[] = []; + + const peer = peers?.find((p) => p.id === peerId); + if (!peer) return; + + const peerGroups = peer.groups || []; + + const peerPolicies = sortBy( + policies?.filter((p) => { + const rule = p.rules?.[0]; + if (!rule) return false; + const sources = rule.sources as Group[]; + return sources?.some((d) => peerGroups?.some((pg) => pg.id === d.id)); + }), + "enabled", + "desc", + ); + + peerPolicies?.forEach((policy) => { + const enabled = policy.enabled; + const nodeExists = allNodes.some((n) => n.id === `policy-${policy.id}`); + if (!nodeExists) { + allNodes.push({ + id: `policy-${policy.id}`, + type: "policyNode", + data: { policy }, + position: { x: 0, y: 0 }, + }); + } + + const edgeExists = allEdges.some( + (e) => e.id === `peer-policy-${peer.id}-${policy.id}`, + ); + if (!edgeExists) { + allEdges.push({ + id: `peer-policy-${peer.id}-${policy.id}`, + source: `select-peer-node`, + target: `policy-${policy.id}`, + type: "in", + data: { enabled, type: "bezier" }, + }); + } + // add destination groups + const destinations = policy.rules?.[0].destinations as Group[]; + destinations?.forEach((destination) => { + const destinationNodeId = `group-${destination.id}`; + const destinationNodeExists = allNodes.some( + (n) => n.id === destinationNodeId, + ); + + if (!destinationNodeExists) { + allNodes.push({ + id: destinationNodeId, + type: "destinationGroupNode", + data: { + group: destination, + enabled, + }, + position: { x: 0, y: 0 }, + }); + } else { + allNodes.forEach((n) => { + if (n.id === destinationNodeId) { + n.data = { + ...n.data, + enabled, + }; + } + }); + } + const destinationEdgeExists = allEdges.some( + (e) => e.id === `policy-group-${policy.id}-${destination.id}`, + ); + if (!destinationEdgeExists) { + allEdges.push({ + id: `policy-group-${policy.id}-${destination.id}`, + source: `policy-${policy.id}`, + target: destinationNodeId, + type: "in", + data: { enabled, type: "bezier" }, + }); + } + + if (selectedDestinationGroup == destination.id) { + const resources = networkResources.filter((n) => { + const resourceGroupIds = + n.groups?.map((g) => (g as Group)?.id) || []; + return resourceGroupIds.includes(destination.id); + }); + + const destinationPeers = peers?.filter((p) => { + const peerGroupIds = p.groups?.map((g) => g.id) || []; + return peerGroupIds.includes(destination.id); + }); + + // add peer nodes + destinationPeers?.forEach((peer) => { + const peerNodeId = `peer-${peer.id}`; + const peerNodeExists = allNodes.some((n) => n.id === peerNodeId); + if (!peerNodeExists) { + allNodes.push({ + id: peerNodeId, + type: "expandedGroupPeer", + data: { + peer, + enabled, + }, + position: { x: 0, y: 0 }, + }); + } else { + allNodes.forEach((n) => { + if (n.id === peerNodeId) { + n.data = { + ...n.data, + enabled, + }; + } + }); + } + + const peerEdgeExists = allEdges.some( + (e) => e.id === `group-peer-${destination.id}-${peer.id}`, + ); + if (!peerEdgeExists) { + allEdges.push({ + id: `group-peer-${destination.id}-${peer.id}`, + source: `group-${destination.id}`, + target: peerNodeId, + type: "simple", + data: { + enabled, + }, + }); + } else { + allEdges.forEach((e) => { + if (e.id === `group-peer-${destination.id}-${peer.id}`) { + e.data = { + ...e.data, + enabled, + }; + } + }); + } + }); + + // add resource nodes + resources.forEach((resource) => { + const resourceNodeId = `resource-${resource.id}`; + const resourceNodeExists = allNodes.some( + (n) => n.id === resourceNodeId, + ); + if (!resourceNodeExists) { + allNodes.push({ + id: resourceNodeId, + type: "resourceNode", + data: { + resource, + enabled, + }, + position: { x: 0, y: 0 }, + }); + } else { + allNodes.forEach((n) => { + if (n.id === resourceNodeId) { + n.data = { + ...n.data, + enabled, + }; + } + }); + } + + const resourceEdgeExists = allEdges.some( + (e) => e.id === `group-resource-${destination.id}-${resource.id}`, + ); + if (!resourceEdgeExists) { + allEdges.push({ + id: `group-resource-${destination.id}-${resource.id}`, + source: `group-${destination.id}`, + target: resourceNodeId, + type: "simple", + data: { + enabled, + }, + }); + } else { + allEdges.forEach((e) => { + if ( + e.id === `group-resource-${destination.id}-${resource.id}` + ) { + e.data = { + ...e.data, + enabled, + }; + } + }); + } + }); + } + }); + }); + + return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "peer", { + policy: { width: 500, spacing: 60 }, + destinationGroup: { width: 1000, spacing: 100 }, + peersAndResources: { width: 1400, spacing: 80 }, + }); + }; + + const fitView = (newNodes?: Node[]) => { + window.requestAnimationFrame(() => + reactFlow.fitView({ + nodes: newNodes ?? nodes, + padding: 0.1, + duration: 750, + maxZoom: DEFAULT_MAX_ZOOM, + minZoom: DEFAULT_MIN_ZOOM, + }), + ); + }; + + const handleGroupChange = (id: string) => { + setNodes((prev) => { + const shouldRecalculate = selectedGroup !== id; + shouldRecalculate && setSelectedGroup(id); + let selectGroupNode; + const previousNodes = prev.map((node) => { + if (node.id === `select-group-node`) { + selectGroupNode = shouldRecalculate + ? { + ...node, + data: { + ...node.data, + currentGroup: id, + }, + } + : node; + return selectGroupNode; + } + return node; + }); + const result = applySingleGroupView(id); + if (result && selectGroupNode) { + let nodesWithCurrentGroup = result.updatedNodes; + nodesWithCurrentGroup.push(selectGroupNode); + setEdges(result.updatedEdges); + setLayoutInitialized(true); + shouldRecalculate && fitView(nodesWithCurrentGroup); + return nodesWithCurrentGroup; + } else { + return previousNodes; + } + }); + }; + + const forceSingleGroupView = (groupId: string) => { + setSelectedGroup(groupId); + setSelectedNetwork(""); + setCurrentView(FlowView.GROUPS); + const selectGroupNode = { + id: `select-group-node`, + type: "selectGroupNode", + position: { x: 0, y: 0 }, + data: { + currentGroup: groupId, + onChange: handleGroupChange, + }, + }; + setNodes([selectGroupNode]); + const result = applySingleGroupView(groupId); + if (result) { + let nodesWithCurrentGroup = result.updatedNodes; + nodesWithCurrentGroup.push(selectGroupNode); + setEdges(result.updatedEdges); + setNodes(nodesWithCurrentGroup); + setLayoutInitialized(true); + fitView(nodesWithCurrentGroup); + } + }; + + useEffect(() => { + if (isLoading) return; + if (layoutInitialized) return; + + switch (currentView) { + case FlowView.PEERS: + if (peers?.length === 0) { + setEdges([]); + setNodes([]); + setLayoutInitialized(true); + fitView([]); + return; + } + + const handlePeerChange = (newPeerId: string) => { + setNodes((prev) => { + const shouldRecalculate = selectedPeer !== newPeerId; + shouldRecalculate && setSelectedPeer(newPeerId); + + let selectPeerNode; + const previousNodes = prev.map((node) => { + if (node.id === `select-peer-node`) { + selectPeerNode = shouldRecalculate + ? { + ...node, + data: { + ...node.data, + currentPeer: newPeerId, + }, + } + : node; + return selectPeerNode; + } + return node; + }); + const result = applyPeerView(newPeerId); + if (result && selectPeerNode) { + let nodesWithCurrentPeer = result.updatedNodes; + nodesWithCurrentPeer.push(selectPeerNode); + setEdges(result.updatedEdges); + setLayoutInitialized(true); + shouldRecalculate && fitView(nodesWithCurrentPeer); + return nodesWithCurrentPeer; + } else { + return previousNodes; + } + }); + }; + + if (selectedPeer === "") { + const userPeer = peers?.find((p) => p.user_id === loggedInUser?.id); + const firstPeer = userPeer ?? peers?.[0]; + const initialPeerId = firstPeer?.id ?? ""; + setNodes([ + { + id: `select-peer-node`, + type: "selectPeerNode", + position: { x: 0, y: 0 }, + data: { + currentPeer: initialPeerId, + onPeerChange: handlePeerChange, + }, + }, + ]); + if (initialPeerId !== "") handlePeerChange(initialPeerId); + } else { + resetView(); + handlePeerChange(selectedPeer); + } + + break; + case FlowView.GROUPS: + if (selectedGroup === "") { + const firstGroup = getFirstGroup(groups, policies); + const initialGroupId = firstGroup?.id ?? ""; + setNodes([ + { + id: `select-group-node`, + type: "selectGroupNode", + position: { x: 0, y: 0 }, + data: { + currentGroup: initialGroupId, + onChange: handleGroupChange, + }, + }, + ]); + if (initialGroupId !== "") { + handleGroupChange(initialGroupId); + } + } else { + resetView(); + handleGroupChange(selectedGroup); + } + break; + case FlowView.NETWORKS: + if (networks?.length === 0) { + setEdges([]); + setNodes([]); + setLayoutInitialized(true); + fitView([]); + return; + } + let result; + if (selectedNetwork) { + result = applySingleNetworkView(selectedNetwork); + } else { + result = applyNetworksView(); + } + if (result) { + setEdges(result.updatedEdges); + setNodes(result.updatedNodes); + setLayoutInitialized(true); + fitView(result.updatedNodes); + } + break; + default: + break; + } + }, [ + currentView, + selectedNetwork, + selectedPeer, + selectedGroup, + isLoading, + layoutInitialized, + ]); + + const resetView = () => { + setLayoutInitialized(false); + }; + + const onNetworkSelect = useCallback((networkId: string) => { + resetView(); + setCurrentView(FlowView.NETWORKS); + setSelectedNetwork(networkId); + }, []); + + const onGroupSelect = useCallback((groupId: string) => { + resetView(); + setCurrentView(FlowView.GROUPS); + setSelectedGroup(groupId); + }, []); + + const onViewChange = (view: FlowView) => { + resetView(); + setSelectedDestinationGroup(""); + setSelectedPeer(""); + setSelectedGroup(""); + setSelectedNetwork(""); + setCurrentView(view); + + try { + const url = new URL(window.location.href); + url.searchParams.delete("tab"); + window.history.replaceState({}, "", url.toString()); + } catch (e) {} + }; + + const currentNetwork = useMemo(() => { + return networks?.find((n) => n.id === selectedNetwork); + }, [networks, selectedNetwork]); + + const onNodeClick = useCallback( + (_event: React.MouseEvent, _node: Node) => { + const isNetworkNode = _node.type === "networkNode"; + const isGroupNode = + _node.type === "groupNode" || _node.type === "sourceGroupNode"; + const isDestinationNode = _node.type === "destinationGroupNode"; + const isPolicyNode = _node.type === "policyNode"; + + const networkId = isNetworkNode ? _node.id.replace("network-", "") : ""; + const groupId = isGroupNode ? _node.id.replace("group-", "") : ""; + const destinationGroupId = isDestinationNode + ? _node.id.replace("group-", "") + : ""; + const policyId = isPolicyNode ? _node.id.replace("policy-", "") : ""; + + if (networkId && currentView === FlowView.NETWORKS) { + onNetworkSelect(networkId); + } + if (currentView === FlowView.PEERS || currentView === FlowView.GROUPS) { + groupId && onGroupSelect(groupId); + destinationGroupId && onDestinationGroupSelect(destinationGroupId); + } + if (policyId) { + setSelectedPolicy(policyId); + setPolicyModalOpen(true); + } + }, + [onNetworkSelect, onGroupSelect, onDestinationGroupSelect, currentView], + ); + + const currentPolicy = useMemo(() => { + return policies?.find((p) => p.id === selectedPolicy); + }, [policies, selectedPolicy]); + + const handlePolicyChange = () => { + setTimeout(() => { + setLayoutInitialized(false); + setSelectedPolicy(""); + setPolicyModalOpen(false); + }, 500); + }; + + const { permission } = usePermissions(); + const router = useRouter(); + + return ( + + {currentPolicy && ( + + )} +
+ {currentView === FlowView.PEERS && + !isPeersLoading && + peers?.length === 0 && ( +
+ +
+ )} + + {currentView === FlowView.NETWORKS && + !isNetworksLoading && + networks?.length === 0 && ( +
+ + } + color={"gray"} + size={"large"} + /> + } + title={"Create New Network"} + description={ + "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network." + } + button={ +
+ +
+ } + learnMore={ + <> + Learn more about + + Networks + + + + } + /> +
+ )} + +
+
+
+ {selectedNetwork === "" && ( + + )} + + {selectedNetwork !== "" && ( + + )} + + {currentView === "networks" && ( +
+ +
+ )} + + {selectedNetwork && currentNetwork && ( + + )} +
+
+
+ +
+
+ +
+
+ + + + + + + + +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index cbefb21..7cb0e4e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -167,4 +167,10 @@ p { .xterm-viewport { @apply m-0 p-0 box-border; -} \ No newline at end of file +} + + +/* Control Center */ +.react-flow__node-groupNode .selected{ + @apply border-netbird; +} diff --git a/src/assets/icons/ControlCenterIcon.tsx b/src/assets/icons/ControlCenterIcon.tsx new file mode 100644 index 0000000..930431f --- /dev/null +++ b/src/assets/icons/ControlCenterIcon.tsx @@ -0,0 +1,22 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function ControlCenterIcon(props: IconProps) { + return ( + + ); +} diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index 40509aa..a7f6fb5 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -1,9 +1,12 @@ "use client"; import { ScrollArea } from "@components/ScrollArea"; +import { SmallBadge } from "@components/ui/SmallBadge"; import { cn } from "@utils/helpers"; +import * as React from "react"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import ActivityIcon from "@/assets/icons/ActivityIcon"; +import ControlCenterIcon from "@/assets/icons/ControlCenterIcon"; import DNSIcon from "@/assets/icons/DNSIcon"; import DocsIcon from "@/assets/icons/DocsIcon"; import PeerIcon from "@/assets/icons/PeerIcon"; @@ -67,6 +70,23 @@ export default function Navigation({ >
+ } + label={ +
+ Control Center + +
+ } + href={"/control-center"} + visible={permission.policies.read} + /> + } label="Peers" diff --git a/src/modules/control-center/FlowSelector.tsx b/src/modules/control-center/FlowSelector.tsx new file mode 100644 index 0000000..413cf7d --- /dev/null +++ b/src/modules/control-center/FlowSelector.tsx @@ -0,0 +1,48 @@ +import { SegmentedTabs } from "@components/SegmentedTabs"; +import { FolderGit2, MonitorSmartphoneIcon, NetworkIcon } from "lucide-react"; +import * as React from "react"; + +export enum FlowView { + NETWORKS = "networks", + GROUPS = "groups", + PEERS = "peers", +} + +type Props = { + value?: FlowView; + onChange?: (value: FlowView) => void; +}; + +export const FlowSelector = ({ value, onChange }: Props) => { + return ( + onChange?.(v as FlowView)}> + + + + Peers + + + + Groups + + + + Networks + + + + ); +}; diff --git a/src/modules/control-center/NetworkRoutingPeerCount.tsx b/src/modules/control-center/NetworkRoutingPeerCount.tsx new file mode 100644 index 0000000..d774fab --- /dev/null +++ b/src/modules/control-center/NetworkRoutingPeerCount.tsx @@ -0,0 +1,48 @@ +import Button from "@components/Button"; +import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; +import * as React from "react"; +import { useMemo } from "react"; +import CircleIcon from "@/assets/icons/CircleIcon"; +import { Network, NetworkRouter } from "@/interfaces/Network"; +import { Peer } from "@/interfaces/Peer"; + +type Props = { + network: Network; +}; + +export const NetworkRoutingPeerCount = ({ network }: Props) => { + const { data: routers, isLoading: isRoutersLoading } = + useFetchApi("/networks/routers"); + const { data: peers, isLoading: isPeersLoading } = + useFetchApi("/peers"); + + const routingPeerStatusColor = useMemo(() => { + if (!network) return "bg-nb-gray-500"; + const routerCount = network.routers?.length || 0; + if (routerCount === 0) return "bg-nb-gray-500"; + if (routerCount === 1) return "bg-yellow-400"; + if (routerCount > 1) return "bg-green-400"; + return "bg-nb-gray-500"; + }, [network]); + + const networkRouters = useMemo(() => { + if (!network || !peers) return []; + const routerIds = network?.routers?.map((r) => r) || []; + return routers?.filter((r) => routerIds.includes(r.id)) || []; + }, [network, peers, routers]); + + return ( + + ); +}; diff --git a/src/modules/control-center/edges/AnimatedLine.tsx b/src/modules/control-center/edges/AnimatedLine.tsx new file mode 100644 index 0000000..a6a80f0 --- /dev/null +++ b/src/modules/control-center/edges/AnimatedLine.tsx @@ -0,0 +1,125 @@ +import { Edge, useInternalNode } from "@xyflow/react"; +import React from "react"; +import { getEdgeParams } from "@/modules/control-center/utils/edge-helper"; + +type AnimatedLineProps = Edge< + { + label?: string; + color?: string; + }, + "animated-line" +>; + +function AnimatedLine({ id, source, target, data }: AnimatedLineProps) { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + if (!sourceNode || !targetNode) return null; + + const { sx, sy, tx, ty } = getEdgeParams(sourceNode, targetNode); + + const labelX = (sx + tx) / 2; + const labelY = (sy + ty) / 2; + + let angle = Math.atan2(ty - sy, tx - sx) * (180 / Math.PI); + if (angle < -90 || angle > 90) { + angle += 180; + } + + const label = data?.label || ""; + const hasLabel = label?.length > 0; + const fontSize = 12; + const paddingX = hasLabel ? 2 : 0; + const paddingY = hasLabel ? 2 : 0; + + const gapWidth = hasLabel ? 4 : 0; + const labelTextWidth = label.length * 7; + + const labelWidth = gapWidth + labelTextWidth + paddingX * 2; + const labelHeight = fontSize + paddingY * 2; + + const dx = tx - sx; + const dy = ty - sy; + const length = Math.sqrt(dx * dx + dy * dy); + const gap = labelWidth / 2; + const nx = dx / length; + const ny = dy / length; + + const preLabelX = labelX - nx * gap; + const preLabelY = labelY - ny * gap; + + const postLabelX = labelX + nx * gap; + const postLabelY = labelY + ny * gap; + + const color = data?.color || "#0e9f6e"; + + return ( + <> + + + + + + + {label && hasLabel && ( + +
+
{label}
+
+
+ )} + + ); +} + +export default AnimatedLine; diff --git a/src/modules/control-center/edges/BidirectionalEdges.tsx b/src/modules/control-center/edges/BidirectionalEdges.tsx new file mode 100644 index 0000000..55f1927 --- /dev/null +++ b/src/modules/control-center/edges/BidirectionalEdges.tsx @@ -0,0 +1,70 @@ +import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react"; +import React from "react"; + +export function BidirectionalEdges({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) { + const [forwardPath] = getSmoothStepPath({ + sourceX: sourceX - 5, + sourceY: sourceY - 5, + sourcePosition, + targetX: targetX + 15, + targetY: targetY - 5, + targetPosition, + }); + + const [backwardPath] = getSmoothStepPath({ + sourceX: targetX + 5, + sourceY: targetY + 5, + sourcePosition: targetPosition, + targetX: sourceX - 15, + targetY: sourceY + 5, + targetPosition: sourcePosition, + }); + + return ( + <> + + + + + + + + + ); +} diff --git a/src/modules/control-center/edges/DirectionIn.tsx b/src/modules/control-center/edges/DirectionIn.tsx new file mode 100644 index 0000000..2f3d8b8 --- /dev/null +++ b/src/modules/control-center/edges/DirectionIn.tsx @@ -0,0 +1,92 @@ +import { + BaseEdge, + type EdgeProps, + getSimpleBezierPath, + getSmoothStepPath, + getStraightPath, +} from "@xyflow/react"; +import React from "react"; + +type Props = { + data: { + enabled: boolean; + type: "smoothstep" | "straight" | "bezier"; + }; +} & EdgeProps; + +export function DirectionIn({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}: Props) { + const { enabled, type = "straight" } = data; + + const getPath = () => { + switch (type) { + case "straight": + return getStraightPath({ + sourceX, + sourceY, + targetX, + targetY, + }); + case "bezier": + return getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + case "smoothstep": + return getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + default: + return getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + } + }; + + const [edgePath] = getPath(); + + return ( + + {enabled && ( + + )} + + ); +} diff --git a/src/modules/control-center/edges/FloatingEdge.tsx b/src/modules/control-center/edges/FloatingEdge.tsx new file mode 100644 index 0000000..6925f85 --- /dev/null +++ b/src/modules/control-center/edges/FloatingEdge.tsx @@ -0,0 +1,53 @@ +import { + BaseEdge, + EdgeProps, + getBezierPath, + useInternalNode, +} from "@xyflow/react"; +import React from "react"; +import { getEdgeParams } from "@/modules/control-center/utils/edge-helper"; + +function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + return ( + + + + ); +} + +export default FloatingEdge; diff --git a/src/modules/control-center/edges/SimpleConnection.tsx b/src/modules/control-center/edges/SimpleConnection.tsx new file mode 100644 index 0000000..b924cfe --- /dev/null +++ b/src/modules/control-center/edges/SimpleConnection.tsx @@ -0,0 +1,45 @@ +import { BaseEdge, type EdgeProps, getSimpleBezierPath } from "@xyflow/react"; +import React from "react"; +import { useSourceGroupEnabled } from "@/modules/control-center/utils/helpers"; + +type Props = { + data: { + enabled: boolean; + }; +} & EdgeProps; + +export function SimpleConnection({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + source, +}: Props) { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const enabled = useSourceGroupEnabled(source); + + return ( + + ); +} diff --git a/src/modules/control-center/nodes/DeviceCard.tsx b/src/modules/control-center/nodes/DeviceCard.tsx new file mode 100644 index 0000000..6d42342 --- /dev/null +++ b/src/modules/control-center/nodes/DeviceCard.tsx @@ -0,0 +1,111 @@ +import TruncatedText from "@components/ui/TruncatedText"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; +import { cn } from "@utils/helpers"; +import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react"; +import * as React from "react"; +import RoundedFlag from "@/assets/countries/RoundedFlag"; +import { NetworkResource } from "@/interfaces/Network"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import type { Peer } from "@/interfaces/Peer"; +import { OSLogo } from "@/modules/peers/PeerOSCell"; + +type DeviceCardProps = { + device?: Peer; + resource?: NetworkResource; + className?: string; +}; + +export const DeviceCard = ({ + device, + resource, + className, +}: DeviceCardProps) => { + if (!device && !resource) return; + return ( +
+
+ {device && } + {resource?.type && } + + {device?.country_code && ( +
+
+ +
+
+ )} +
+
+ + + + + {device?.ip || resource?.address} + +
+
+ ); +}; + +const PeerOSIcon = ({ os }: { os: string }) => { + const osType = getOperatingSystem(os); + return ( +
+ +
+ ); +}; + +const ResourceIcon = ({ + type, + size = 15, +}: { + type: "domain" | "host" | "subnet"; + size?: number; +}) => { + switch (type) { + case "domain": + return ; + case "subnet": + return ; + case "host": + return ; + default: + return ; + } +}; diff --git a/src/modules/control-center/nodes/GroupNode.tsx b/src/modules/control-center/nodes/GroupNode.tsx new file mode 100644 index 0000000..d30b81f --- /dev/null +++ b/src/modules/control-center/nodes/GroupNode.tsx @@ -0,0 +1,80 @@ +import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon"; +import { cn } from "@utils/helpers"; +import { Handle, type Node, Position } from "@xyflow/react"; +import * as React from "react"; +import { useMemo } from "react"; +import { Group } from "@/interfaces/Group"; + +type GroupNodeProps = Node< + { + group: Group; + enabled: boolean; + hoverable?: boolean; + onClick?: (g: Group) => void; + }, + "groupNode" +>; + +export const GroupNode = ({ data, id }: GroupNodeProps) => { + const { enabled = true, group, hoverable = true, onClick } = data; + + const countLabel = useMemo(() => { + const peerCount = group?.peers_count || 0; + const resourceCount = group?.resources_count || 0; + if (resourceCount === 0) { + return `${peerCount} Peer(s)`; + } + if (peerCount === 0) { + return `${resourceCount} Resource(s)`; + } + return `${peerCount} Peer(s), ${resourceCount} Resource(s)`; + }, [group?.peers_count, group?.resources_count]); + + return ( +
onClick?.(group)} + > +
+
+
+ +
+
+
+ {group.name} +
+
+ {countLabel} +
+
+
+
+ + + +
+ ); +}; diff --git a/src/modules/control-center/nodes/NetworkNode.tsx b/src/modules/control-center/nodes/NetworkNode.tsx new file mode 100644 index 0000000..da8c2bb --- /dev/null +++ b/src/modules/control-center/nodes/NetworkNode.tsx @@ -0,0 +1,102 @@ +import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; +import { Handle, type Node, Position } from "@xyflow/react"; +import { NetworkIcon } from "lucide-react"; +import * as React from "react"; +import CircleIcon from "@/assets/icons/CircleIcon"; +import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; +import { Network, NetworkResource } from "@/interfaces/Network"; + +type NetworkNodeType = { + network: Network; +}; + +type NetworkNodeProps = Node; + +export const NetworkNode = ({ data }: NetworkNodeProps) => { + const { data: networkResources, isLoading: isLoadingResources } = useFetchApi< + NetworkResource[] + >("/networks/resources"); + + const n = data.network as Network; + const resourceIds = n?.resources || []; + const routingPeers = n?.routers || []; + const resources = + networkResources?.filter((r) => resourceIds.includes(r?.id || "")) || []; + + return ( +
+
+
+
+
+ + {n?.name} +
+
+ {resources?.length || 0} Resources +
+
+
+
+ 1 && "bg-green-400", + )} + /> + {routingPeers?.length || 0} Routing Peer(s) +
+
+ + {resources && resources.length > 0 && ( +
+
+ {resources?.slice(0, 6).map((r) => { + return ; + })} +
+
6 ? "opacity-100" : "opacity-0", + )} + >
+
+ )} + + + +
+ ); +}; diff --git a/src/modules/control-center/nodes/PeerNode.tsx b/src/modules/control-center/nodes/PeerNode.tsx new file mode 100644 index 0000000..c9fe529 --- /dev/null +++ b/src/modules/control-center/nodes/PeerNode.tsx @@ -0,0 +1,44 @@ +import { cn } from "@utils/helpers"; +import { Handle, type Node, Position } from "@xyflow/react"; +import * as React from "react"; +import type { Peer } from "@/interfaces/Peer"; +import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; +import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers"; + +type PeerNodeProps = Node< + { + peer: Peer; + enabled?: boolean; + }, + "peerNode" +>; + +export const PeerNode = ({ data, id }: PeerNodeProps) => { + const { peer, enabled } = data; + const isEnabled = useAnySourceGroupEnabled(id); + + return ( +
+ + + +
+ ); +}; diff --git a/src/modules/control-center/nodes/PolicyNode.tsx b/src/modules/control-center/nodes/PolicyNode.tsx new file mode 100644 index 0000000..4e94751 --- /dev/null +++ b/src/modules/control-center/nodes/PolicyNode.tsx @@ -0,0 +1,66 @@ +import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon"; +import { cn } from "@utils/helpers"; +import { Handle, type Node, Position } from "@xyflow/react"; +import * as React from "react"; +import { getPolicyProtocolAndPortText } from "@/modules/control-center/utils/helpers"; +import { Policy } from "@/interfaces/Policy"; + +type PolicyNode = Node< + { + policy: Policy; + }, + "policyNode" +>; + +export const PolicyNode = ({ data }: PolicyNode) => { + const rule = data.policy.rules?.[0]; + const label = getPolicyProtocolAndPortText(data.policy); + const isActive = rule?.enabled; + + return ( +
+
+
+
+
+
+
{rule?.name}
+
+
+
+
{label === "" ? "All" : label}
+
+ + + +
+ ); +}; diff --git a/src/modules/control-center/nodes/ResourceNode.tsx b/src/modules/control-center/nodes/ResourceNode.tsx new file mode 100644 index 0000000..c70bf01 --- /dev/null +++ b/src/modules/control-center/nodes/ResourceNode.tsx @@ -0,0 +1,41 @@ +import { cn } from "@utils/helpers"; +import { Handle, type Node, Position } from "@xyflow/react"; +import * as React from "react"; +import { NetworkResource } from "@/interfaces/Network"; +import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; +import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers"; + +type ResourceNode = Node< + { + resource: NetworkResource; + enabled?: boolean; + }, + "resourceNode" +>; + +export const ResourceNode = ({ data, id }: ResourceNode) => { + const { enabled, resource } = data; + + const isEnabled = useAnySourceGroupEnabled(id); + + return ( +
+ + +
+ ); +}; diff --git a/src/modules/control-center/nodes/SelectGroupNode.tsx b/src/modules/control-center/nodes/SelectGroupNode.tsx new file mode 100644 index 0000000..02f152d --- /dev/null +++ b/src/modules/control-center/nodes/SelectGroupNode.tsx @@ -0,0 +1,135 @@ +import { + SelectDropdown, + SelectOption, +} from "@components/select/SelectDropdown"; +import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon"; +import useFetchApi from "@utils/api"; +import { Handle, type Node, Position } from "@xyflow/react"; +import { sortBy } from "lodash"; +import { ChevronsUpDown } from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import { Group } from "@/interfaces/Group"; + +type NodeProps = Node< + { + currentGroup: string; + onChange: (id: string) => void; + }, + "selectGroupNode" +>; + +export const SelectGroupNode = ({ data, id }: NodeProps) => { + const { data: groups, isLoading: isGroupsLoading } = + useFetchApi("/groups"); + + const groupOptions: SelectOption[] = sortBy( + groups?.map( + (g) => + ({ + value: g.id, + label: g.name, + icon: () => ( + + ), + }) as SelectOption, + ) || [], + "label", + "asc", + ); + + const group = groups?.find((g) => g.id === data.currentGroup); + + const countLabel = useMemo(() => { + const peerCount = group?.peers_count || 0; + const resourceCount = group?.resources_count || 0; + if (resourceCount === 0) { + return `${peerCount} Peer(s)`; + } + if (peerCount === 0) { + return `${resourceCount} Resource(s)`; + } + return `${peerCount} Peer(s), ${resourceCount} Resource(s)`; + }, [group]); + + return ( +
+ +
+ {group && ( +
+
+
+ +
+
+
+ {group.name} +
+
+ {countLabel} +
+
+
+
+ )} + +
+
+ + +
+ ); +}; diff --git a/src/modules/control-center/nodes/SelectPeerNode.tsx b/src/modules/control-center/nodes/SelectPeerNode.tsx new file mode 100644 index 0000000..c3161a7 --- /dev/null +++ b/src/modules/control-center/nodes/SelectPeerNode.tsx @@ -0,0 +1,102 @@ +import { + SelectDropdown, + SelectOption, +} from "@components/select/SelectDropdown"; +import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; +import { Handle, type Node, Position } from "@xyflow/react"; +import { sortBy } from "lodash"; +import { ChevronsUpDown } from "lucide-react"; +import * as React from "react"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import type { Peer } from "@/interfaces/Peer"; +import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard"; +import { OSLogo } from "@/modules/peers/PeerOSCell"; + +type PeerNodeProps = Node< + { + currentPeer: string; + onPeerChange: (peerId: string) => void; + }, + "selectPeerNode" +>; + +export const SelectPeerNode = ({ data, id }: PeerNodeProps) => { + const { data: peers, isLoading: isPeersLoading } = + useFetchApi("/peers"); + + const peerSelectOptions: SelectOption[] = sortBy( + peers?.map( + (p) => + ({ + value: p.id, + label: p.name, + icon: () => { + const os = p.os as unknown as OperatingSystem; + return ( +
+ +
+ ); + }, + }) as SelectOption, + ) || [], + "label", + "asc", + ); + + const peer = peers?.find((p) => p.id === data.currentPeer); + + return ( +
+ +
+ {peer && } + +
+
+ + +
+ ); +}; diff --git a/src/modules/control-center/utils/edge-helper.ts b/src/modules/control-center/utils/edge-helper.ts new file mode 100644 index 0000000..9c42048 --- /dev/null +++ b/src/modules/control-center/utils/edge-helper.ts @@ -0,0 +1,90 @@ +import { InternalNode, Node, Position } from "@xyflow/react"; + +type IntersectionPoint = { + x: number; + y: number; +}; + +function getNodeIntersection( + intersectionNode: InternalNode, + targetNode: InternalNode, +) { + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = + intersectionNode.measured; + const intersectionNodePosition = intersectionNode.internals.positionAbsolute; + const targetPosition = targetNode.internals.positionAbsolute; + const measuredTargetWidth = targetNode.measured.width || 0; + const measuredTargetHeight = targetNode.measured.height || 0; + + const w = (intersectionNodeWidth || 0) / 2; + const h = (intersectionNodeHeight || 0) / 2; + + const x2 = intersectionNodePosition.x + w; + const y2 = intersectionNodePosition.y + h; + const x1 = targetPosition.x + measuredTargetWidth / 2; + const y1 = targetPosition.y + measuredTargetHeight / 2; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; + + return { x, y }; +} + +function getEdgePosition( + node: InternalNode, + intersectionPoint: IntersectionPoint, +) { + const n = { ...node.internals.positionAbsolute, ...node }; + const nx = Math.round(n.x); + const ny = Math.round(n.y); + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + const measuredWidth = n.measured.width || 0; + const measuredHeight = n.measured.height || 0; + + if (px <= nx + 1) { + return Position.Left; + } + if (px >= nx + measuredWidth - 1) { + return Position.Right; + } + if (py <= ny + 1) { + return Position.Top; + } + if (py >= n.y + measuredHeight - 1) { + return Position.Bottom; + } + + return Position.Top; +} + +export function getEdgeParams( + source: InternalNode, + target: InternalNode, +) { + const sourceIntersectionPoint: IntersectionPoint = getNodeIntersection( + source, + target, + ); + const targetIntersectionPoint: IntersectionPoint = getNodeIntersection( + target, + source, + ); + + const sourcePos = getEdgePosition(source, sourceIntersectionPoint); + const targetPos = getEdgePosition(target, targetIntersectionPoint); + + return { + sx: sourceIntersectionPoint.x, + sy: sourceIntersectionPoint.y, + tx: targetIntersectionPoint.x, + ty: targetIntersectionPoint.y, + sourcePos, + targetPos, + }; +} diff --git a/src/modules/control-center/utils/edges.ts b/src/modules/control-center/utils/edges.ts new file mode 100644 index 0000000..5c4aa5f --- /dev/null +++ b/src/modules/control-center/utils/edges.ts @@ -0,0 +1,13 @@ +import AnimatedLine from "@/modules/control-center/edges/AnimatedLine"; +import { BidirectionalEdges } from "@/modules/control-center/edges/BidirectionalEdges"; +import { DirectionIn } from "@/modules/control-center/edges/DirectionIn"; +import FloatingEdge from "@/modules/control-center/edges/FloatingEdge"; +import { SimpleConnection } from "@/modules/control-center/edges/SimpleConnection"; + +export const EDGE_TYPES = { + in: DirectionIn, + bi: BidirectionalEdges, + floating: FloatingEdge, + "floating-straight": AnimatedLine, + simple: SimpleConnection, +}; diff --git a/src/modules/control-center/utils/helpers.ts b/src/modules/control-center/utils/helpers.ts new file mode 100644 index 0000000..70cd373 --- /dev/null +++ b/src/modules/control-center/utils/helpers.ts @@ -0,0 +1,145 @@ +import { useReactFlow } from "@xyflow/react"; +import { orderBy } from "lodash"; +import { Group } from "@/interfaces/Group"; +import { Network } from "@/interfaces/Network"; +import { Peer } from "@/interfaces/Peer"; +import { Policy } from "@/interfaces/Policy"; + +export const getDestinationGroupsFromPolicy = (policy: Policy) => { + const rule = policy.rules?.[0]; + if (!rule) return []; + const destinations = rule.destinations as Group[]; + if (!destinations) return []; + return destinations; +}; + +export const getSourceGroupsFromPolicy = (policy: Policy) => { + const rule = policy.rules?.[0]; + if (!rule) return []; + const sources = rule.sources as Group[]; + if (!sources) return []; + return sources; +}; + +export const getNetworksFromPolicy = (networks: Network[], policy: Policy) => { + const policyId = policy.id; + if (!policyId) return []; + return networks.filter((network) => { + return network.policies?.some((p) => p === policyId); + }); +}; + +export const getPeersFromGroup = (group: Group, peers: Peer[]) => { + return peers.filter((peer) => { + const groupIds = peer.groups?.map((g) => g.id) || []; + return groupIds.includes(group.id); + }); +}; + +export const getPolicyProtocolAndPortText = ( + policy: Policy, + maxPorts?: number, +) => { + const rule = policy.rules?.[0]; + if (!rule) return ""; + let p = rule.protocol; + + if (p === "all") { + return ""; + } else if (p === "icmp") { + return "ICMP"; + } else { + const ports = getPolicyPortsText(policy); + if (!ports || ports.length === 0) { + return p.toUpperCase(); + } + if (ports.length > (maxPorts ?? 3)) { + const firstFour = ports.slice(0, 4); + return `${p.toUpperCase()}:${firstFour.join(",")}, ...`; + } + return `${p.toUpperCase()}:${ports.join(",")}`; + } +}; + +export const getPolicyPortsText = (policy: Policy) => { + const rule = policy.rules?.[0]; + if (!rule) return undefined; + + const ports = rule.ports || []; + const portRanges = rule.port_ranges || []; + + if (ports.length === 0 && portRanges.length === 0) { + return undefined; + } + + const portStrings = ports.map((port) => String(port)); + const rangeStrings = portRanges.map((range) => { + if (range.start === range.end) return String(range.start); + return `${range.start}-${range.end}`; + }); + + return orderBy( + [...portStrings, ...rangeStrings], + [(x) => Number(x.split("-")[0])], + ["asc"], + ); +}; + +export const getResourcePolicyByGroups = ( + groups: Group[], + policies: Policy[], +): Policy[] => { + const groupIds = groups.map((group) => group.id); + return policies.filter((policy) => { + const rule = policy.rules?.[0]; + if (!rule) return false; + const destinations = rule.destinations as Group[]; + return destinations?.some((d) => groupIds.includes(d.id)); + }); +}; + +export function useSourceGroupEnabled(sourceId: string) { + const { getNode } = useReactFlow(); + const node = getNode(sourceId); + return node?.data?.enabled ?? false; +} + +export function useAnySourceGroupEnabled(sourceId: string) { + const { getNodes, getEdges } = useReactFlow(); + + const nodes = getNodes(); + const edges = getEdges(); + + const incomingEdges = edges.filter((e) => e.target === sourceId); + const sourceNodes = incomingEdges + .map((edge) => nodes.find((n) => n.id === edge.source)) + .filter(Boolean); + const sourceEnabledStates = sourceNodes.map((n) => n?.data?.enabled); + return sourceEnabledStates.some(Boolean); +} + +export function getFirstGroup(groups?: Group[], policies?: Policy[]) { + const sortedGroups = orderBy(groups, "peers_count", "desc"); + const groupsWithoutAll = sortedGroups?.filter((g) => g.name !== "All"); + + const groupsWithPolicies = orderBy( + groupsWithoutAll?.filter((g) => { + return policies?.some((p) => { + const sources = getSourceGroupsFromPolicy(p); + return sources?.some((source) => source.id === g.id); + }); + }), + "peers_count", + "desc", + ); + + if (groupsWithPolicies && groupsWithPolicies?.length > 0) { + return groupsWithPolicies[0]; + } + + if (groupsWithoutAll && groupsWithoutAll?.length > 0) { + return groupsWithoutAll[0]; + } + + return sortedGroups?.[0]; +} diff --git a/src/modules/control-center/utils/layouts.ts b/src/modules/control-center/utils/layouts.ts new file mode 100644 index 0000000..6dea7c8 --- /dev/null +++ b/src/modules/control-center/utils/layouts.ts @@ -0,0 +1,245 @@ +import { Edge, Node } from "@xyflow/react"; +import * as d3 from "d3"; + +interface SimulationNode extends Node { + x: number; + y: number; + vx?: number; + vy?: number; +} + +export const DEFAULT_MAX_ZOOM = 0.8; +export const DEFAULT_MIN_ZOOM = 0.2; + +export const applyD3ForceLayout = (nodes: Node[], edges: Edge[]) => { + const simulationNodes: SimulationNode[] = nodes.map((node) => ({ + ...node, + x: node.position?.x || 0, + y: node.position?.y || 0, + })); + + const simulationLinks = edges.map((edge) => ({ + ...edge, + source: edge.source, + target: edge.target, + })); + + // Apply minimal D3 simulation for final positioning with reduced link distance + const simulation = d3 + .forceSimulation(simulationNodes) + .force( + "link", + d3 + .forceLink(simulationLinks) + .id((d: any) => d.id) + .distance(60) // Reduced distance to minimize crossings + .strength(0.05), // Reduced strength to maintain radial structure + ) + .force("collision", d3.forceCollide().radius(300)); + + // Run simulation for fewer iterations to preserve radial structure + for (let i = 0; i < 1000; i++) { + simulation.tick(); + } + + const updatedNodes: Node[] = simulationNodes.map((node) => ({ + ...node, + position: { + x: node.x, + y: node.y, + }, + })); + + const updatedEdges: Edge[] = edges.map((edge) => { + const sourceNode = simulationNodes.find((n) => n.id === edge.source); + const targetNode = simulationNodes.find((n) => n.id === edge.target); + + return { + ...edge, + data: { + ...edge.data, + points: + sourceNode && targetNode + ? [ + { x: sourceNode.x, y: sourceNode.y }, + { x: targetNode.x, y: targetNode.y }, + ] + : undefined, + }, + }; + }); + + simulation.stop(); + + return { updatedNodes, updatedEdges }; +}; + +export const applyD3HierarchicalLayout = ( + nodes: Node[], + edges: Edge[], + width = 280, + spacing = 100, + view?: string, + options?: { + policy?: { width: number; spacing: number }; + destinationGroup?: { width: number; spacing: number }; + peersAndResources?: { width: number; spacing: number }; + }, +) => { + const simulationNodes: SimulationNode[] = nodes.map((node) => ({ + ...node, + x: node.position?.x || 0, + y: node.position?.y || 0, + })); + + const columnWidth = width; + const nodeSpacing = spacing; + const startX = 0; + const centerY = 0; + + const groupNodes = simulationNodes.filter((n) => n.type === "groupNode"); + const sourceGroupNodes = simulationNodes.filter( + (n) => n.type === "sourceGroupNode", + ); + const destinationGroupNodes = simulationNodes.filter( + (n) => n.type === "destinationGroupNode", + ); + const policyNodes = simulationNodes.filter((n) => n.type === "policyNode"); + const networkNodes = simulationNodes.filter((n) => n.type === "networkNode"); + const resourceNodes = simulationNodes.filter( + (n) => n.type === "resourceNode", + ); + const peerNodes = simulationNodes.filter((n) => n.type === "peerNode"); + const expandedGroupPeers = simulationNodes.filter( + (n) => n.type === "expandedGroupPeer", + ); + + let networkAndResourceNodes = [...networkNodes, ...resourceNodes]; + + if (view === "group") { + networkAndResourceNodes = [...networkAndResourceNodes, ...peerNodes]; + } + + if (view === "peer") { + networkAndResourceNodes = [ + ...networkAndResourceNodes, + ...expandedGroupPeers, + ]; + } + + // Peers + if (peerNodes.length > 0 && view !== "group") { + centerNodesVertically( + peerNodes, + startX + (view === "group" ? columnWidth * 4 : 0), + nodeSpacing, + centerY, + ); + } + + // Groups or Source Groups + centerNodesVertically(groupNodes, startX, nodeSpacing, centerY); + centerNodesVertically( + sourceGroupNodes, + startX + columnWidth, + nodeSpacing, + centerY, + ); + + // Policies + centerNodesVertically( + policyNodes, + startX + (options?.policy?.width ?? columnWidth), + options?.policy?.spacing ?? nodeSpacing, + centerY + 14, + ); + + // Destination Groups + centerNodesVertically( + destinationGroupNodes, + startX + (options?.destinationGroup?.width ?? columnWidth), + options?.destinationGroup?.spacing ?? nodeSpacing, + centerY, + ); + + // Networks + centerNodesVertically( + networkAndResourceNodes, + startX + (options?.peersAndResources?.width ?? columnWidth), + options?.peersAndResources?.spacing ?? nodeSpacing, + centerY + 5, + ); + + const simulation = d3 + .forceSimulation(simulationNodes) + .force("charge", d3.forceManyBody().strength(0)) + .force("collision", d3.forceCollide().radius(0)) + .alphaDecay(0.05) + .velocityDecay(0.7); + + simulation.force("position", (alpha) => { + simulationNodes.forEach((node) => { + let targetX = node.x; + let targetY = node.y; + + const dx = targetX - node.x; + const dy = targetY - node.y; + + node.vx = (node.vx || 0) + dx * alpha * 0.1; + node.vy = (node.vy || 0) + dy * alpha * 0.1; + }); + }); + + for (let i = 0; i < 100; i++) { + simulation.tick(); + } + + const updatedNodes: Node[] = simulationNodes.map((node) => ({ + ...node, + position: { + x: node.x, + y: node.y, + }, + })); + + const updatedEdges: Edge[] = edges.map((edge) => { + const sourceNode = simulationNodes.find((n) => n.id === edge.source); + const targetNode = simulationNodes.find((n) => n.id === edge.target); + + return { + ...edge, + data: { + ...edge.data, + points: + sourceNode && targetNode + ? [ + { x: sourceNode.x, y: sourceNode.y }, + { x: targetNode.x, y: targetNode.y }, + ] + : undefined, + }, + }; + }); + + simulation.stop(); + + return { updatedNodes, updatedEdges }; +}; + +const centerNodesVertically = ( + nodesList: SimulationNode[], + x: number, + nodeSpacing: number, + centerY: number, + enable = true, +) => { + if (nodesList.length === 0) return; + + const totalHeight = (nodesList.length - 1) * nodeSpacing; + const startY = centerY - totalHeight / 2; + + nodesList.forEach((node, index) => { + node.x = x; + node.y = (enable ? startY : 0) + index * nodeSpacing; + }); +}; diff --git a/src/modules/control-center/utils/nodes.ts b/src/modules/control-center/utils/nodes.ts new file mode 100644 index 0000000..6f3eb7c --- /dev/null +++ b/src/modules/control-center/utils/nodes.ts @@ -0,0 +1,20 @@ +import { GroupNode } from "@/modules/control-center/nodes/GroupNode"; +import { NetworkNode } from "@/modules/control-center/nodes/NetworkNode"; +import { PeerNode } from "@/modules/control-center/nodes/PeerNode"; +import { PolicyNode } from "@/modules/control-center/nodes/PolicyNode"; +import { ResourceNode } from "@/modules/control-center/nodes/ResourceNode"; +import { SelectGroupNode } from "@/modules/control-center/nodes/SelectGroupNode"; +import { SelectPeerNode } from "@/modules/control-center/nodes/SelectPeerNode"; + +export const NODE_TYPES = { + groupNode: GroupNode, + sourceGroupNode: GroupNode, + destinationGroupNode: GroupNode, + networkNode: NetworkNode, + resourceNode: ResourceNode, + policyNode: PolicyNode, + peerNode: PeerNode, + expandedGroupPeer: PeerNode, + selectPeerNode: SelectPeerNode, + selectGroupNode: SelectGroupNode, +}; diff --git a/src/modules/remote-access/rdp/useRemoteDesktop.ts b/src/modules/remote-access/rdp/useRemoteDesktop.ts index 636bcac..fad247d 100644 --- a/src/modules/remote-access/rdp/useRemoteDesktop.ts +++ b/src/modules/remote-access/rdp/useRemoteDesktop.ts @@ -38,7 +38,8 @@ export enum RDPStatus { CONNECTING = 2, } -export const RDP_DOCS_LINK = "https://docs.netbird.io/"; +export const RDP_DOCS_LINK = + "https://docs.netbird.io/how-to/browser-client#rdp-connection"; export const useRemoteDesktop = (client: any) => { const [status, setStatus] = useState(RDPStatus.DISCONNECTED); diff --git a/src/modules/remote-access/ssh/SSHTooltip.tsx b/src/modules/remote-access/ssh/SSHTooltip.tsx index 779920d..8f67aee 100644 --- a/src/modules/remote-access/ssh/SSHTooltip.tsx +++ b/src/modules/remote-access/ssh/SSHTooltip.tsx @@ -2,7 +2,6 @@ import FullTooltip from "@components/FullTooltip"; import InlineLink from "@components/InlineLink"; import { ExternalLinkIcon } from "lucide-react"; import * as React from "react"; -import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH"; type Props = { disabled?: boolean; @@ -34,7 +33,10 @@ export const SSHTooltip = ({
Learn more about{" "} - + SSH
diff --git a/src/modules/remote-access/ssh/useSSH.ts b/src/modules/remote-access/ssh/useSSH.ts index 7f40b2c..e26ae3b 100644 --- a/src/modules/remote-access/ssh/useSSH.ts +++ b/src/modules/remote-access/ssh/useSSH.ts @@ -20,7 +20,8 @@ export enum SSHStatus { CONNECTING = 2, } -export const SSH_DOCS_LINK = "https://docs.netbird.io/"; +export const SSH_DOCS_LINK = + "https://docs.netbird.io/how-to/browser-client#ssh-connection"; export const useSSH = (client: any) => { const [status, setStatus] = useState(SSHStatus.DISCONNECTED); diff --git a/tailwind.config.ts b/tailwind.config.ts index dc439e5..a432120 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -28,6 +28,7 @@ const config: Config = { "920": "#25282d", "925": "#1e2123", "930": "#25282c", + "935": "#1f2124", "940": "#1c1d21", "950": "#181a1d", },