Compare commits

...

3 Commits

Author SHA1 Message Date
Eduard Gert
9e2e38764e Add control center (#494)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add control center

* Update rdp doc link
2025-10-09 11:26:21 +02:00
Maycon Santos
d9fb379abf Enable connect buttons (#493)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-06 16:23:00 -03:00
Eduard Gert
831673d0d6 Sync with cloud (#491)
Some checks failed
build and push / build_n_push (push) Has been cancelled
implements a "Sync with cloud" functionality that includes various UI improvements, code refactoring, and component extractions. The changes focus on enhancing the user interface, improving code organization, and adding new features for remote access and activity tracking.

- Refactors inline components into reusable shared components
- Adds new activity tracking for group operations
- Updates remote access configuration and UI components
- Enhances styling and layout for better user experience
2025-10-03 14:37:11 +02:00
64 changed files with 4549 additions and 582 deletions

View File

@@ -71,7 +71,6 @@ jobs:
images: ${{ env.IMAGE_NAME }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.NB_DOCKER_USER }}
@@ -82,7 +81,7 @@ jobs:
with:
context: .
file: docker/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: true
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -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

View File

@@ -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;

824
package-lock.json generated
View File

@@ -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
}
}
}
}
}

View File

@@ -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",

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -66,8 +66,8 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -350,15 +350,15 @@ const PeerGeneralInformation = () => {
/>
</FullTooltip>
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
{permission.groups.read && (
<div>
@@ -582,6 +582,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
/>
)}
{peer.created_at && (
<Card.ListItem
label={
<>
<CalendarDays size={16} />
Registered on
</>
}
value={
dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.created_at) +
")"
}
/>
)}
<Card.ListItem
label={
<>

View File

@@ -167,4 +167,10 @@ p {
.xterm-viewport {
@apply m-0 p-0 box-border;
}
}
/* Control Center */
.react-flow__node-groupNode .selected{
@apply border-netbird;
}

View File

@@ -35,6 +35,7 @@ export default function NotFound() {
}
const Redirect = ({ url, queryParams }: Props) => {
useRedirect("/peers" + (queryParams && `?${queryParams}`));
const params = queryParams && `?${queryParams}`;
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
return <FullScreenLoading />;
};

View File

@@ -0,0 +1,22 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ControlCenterIcon(props: IconProps) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
{...iconProperties(props)}
>
<path d="M5 3a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm0 12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2H5Zm12 0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-2Zm0-12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-2Z" />
<path
fillRule="evenodd"
d="M10 6.5a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1ZM10 18a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm-4-4a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Zm12 0a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -58,6 +58,8 @@ export default function OIDCProvider({ children }: Props) {
"utm_content",
"utm_campaign",
"hs_id",
"page",
"page_size",
"user",
"port",
];

View File

@@ -34,7 +34,7 @@ export const buttonVariants = cva(
secondary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
],
secondaryLighter: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",

View File

@@ -25,6 +25,8 @@ type Props = {
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
delayDuration?: number;
skipDelayDuration?: number;
alignOffset?: number;
sideOffset?: number;
} & TooltipProps &
TooltipVariants;
@@ -45,6 +47,8 @@ export default function FullTooltip({
delayDuration = 1,
skipDelayDuration = 300,
variant = "default",
alignOffset = 20,
sideOffset,
}: Props) {
const [open, setOpen] = useState(!!keepOpen);
@@ -83,7 +87,8 @@ export default function FullTooltip({
)}
{!disabled && (
<TooltipContent
alignOffset={20}
alignOffset={alignOffset}
sideOffset={sideOffset}
forceMount={true}
className={contentClassName}
variant={variant}

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { cn } from "@utils/helpers";
export const ListItem = ({
icon,
label,
value,
className,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import AddPeerButton from "@components/ui/AddPeerButton";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ExternalLinkIcon } from "lucide-react";
import * as React from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
type Props = {
showBackground?: boolean;
};
export const NoPeersGettingStarted = ({ showBackground = true }) => {
return (
<GetStartedTest
showBackground={showBackground}
icon={
<SquareIcon
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Get Started with NetBird"}
description={
"It looks like you don't have any connected machines.\n" +
"Get started by adding one to your network."
}
button={<AddPeerButton />}
learnMore={
<>
Learn more in our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started"}
target={"_blank"}
>
Getting Started Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
);
};

View File

@@ -42,13 +42,13 @@ import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item?.id?.toLowerCase().includes(lowerCaseQuery) ?? false;
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item?.id?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
interface MultiSelectProps {
@@ -104,11 +104,11 @@ export function PeerGroupSelector({
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
NetworkResource[]
>("/networks/resources");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
useFetchApi<Peer[]>("/peers");
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
useGroups();
@@ -118,18 +118,19 @@ export function PeerGroupSelector({
const [inputRef, { width }] = useElementSize<
HTMLButtonElement | HTMLSpanElement
>();
const [open, setOpen] = useState(false);
const sortedDropdownOptions = useSortedDropdownOptions(
dropdownOptions,
values,
open,
dropdownOptions,
values,
open,
);
const [filteredGroups, search, setSearch] = useSearch(
sortedDropdownOptions,
groupsSearchPredicate,
{ filter: true, debounce: 150 },
sortedDropdownOptions,
groupsSearchPredicate,
{ filter: true, debounce: 150 },
);
// Update dropdown options when groups change
@@ -247,10 +248,10 @@ export function PeerGroupSelector({
}, [tab]);
const searchPlaceholder = useMemo(() => {
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
return "Search...";
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
return "Search...";
}, [tab, placeholderForSearch]);
const selectResource = (resource?: NetworkResource) => {
@@ -266,12 +267,12 @@ export function PeerGroupSelector({
};
const selectPeer = (peer?: Peer) => {
if (!peer?.id) return;
onResourceChange?.({
id: peer.id,
type: "peer",
});
onChange([]);
if (!peer?.id) return;
onResourceChange?.({
id: peer.id,
type: "peer",
});
onChange([]);
};
return (
@@ -389,7 +390,7 @@ export function PeerGroupSelector({
side={side}
sideOffset={10}
>
<Command className={"w-full flex"} loop shouldFilter={false}>
<Command className={"w-full flex"} loop shouldFilter={false}>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
@@ -430,17 +431,17 @@ export function PeerGroupSelector({
</div>
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
<TabTriggers
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
/>
<TabTriggers
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
/>
<TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup>
<ScrollArea
className={cn(
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
filteredGroups.length == 0 && !search && "py-0",
filteredGroups.length == 0 && !search && "py-0",
)}
>
{searchedGroupNotFound && (
@@ -453,8 +454,8 @@ export function PeerGroupSelector({
value={search}
onClick={(e) => e.preventDefault()}
>
<Badge variant={"gray-ghost"} className={"h-7"}>
<FolderGit2 size={12} className={"shrink-0"} />
<Badge variant={"gray-ghost"} className={"h-7"}>
<FolderGit2 size={12} className={"shrink-0"} />
{search}
</Badge>
<div
@@ -510,11 +511,11 @@ export function PeerGroupSelector({
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<GroupBadge
group={option}
showNewBadge={true}
className={"h-7"}
/>
<GroupBadge
group={option}
showNewBadge={true}
className={"h-7"}
/>
</div>
<div className={"flex items-center gap-5"}>
@@ -533,10 +534,10 @@ export function PeerGroupSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
<MonitorSmartphoneIcon
size={14}
className={"shrink-0"}
/>
<MonitorSmartphoneIcon
size={14}
className={"shrink-0"}
/>
{peerCount} Peer(s)
</div>
) : (
@@ -568,17 +569,17 @@ export function PeerGroupSelector({
/>
</TabsContent>
)}
{showPeers && (
<TabsContent value={"peers"} className={"p-0 my-0"}>
<PeersList
search={search}
peers={peers}
isLoading={isPeersLoading}
value={resource}
onChange={selectPeer}
/>
</TabsContent>
)}
{showPeers && (
<TabsContent value={"peers"} className={"p-0 my-0"}>
<PeersList
search={search}
peers={peers}
isLoading={isPeersLoading}
value={resource}
onChange={selectPeer}
/>
</TabsContent>
)}
</Tabs>
</CommandList>
</Command>
@@ -597,6 +598,7 @@ const TabTriggers = ({
showPeers?: boolean;
}) => {
if (!showResources && !showPeers) return null;
return (
<TabsList justify={"start"} className={"px-3"}>
<TabsTrigger
@@ -613,37 +615,37 @@ const TabTriggers = ({
Groups
</TabsTrigger>
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
</TabsList>
);
};
@@ -800,105 +802,105 @@ const ResourcesList = ({
};
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
};
const PeersList = ({
search,
peers,
isLoading,
value,
onChange,
}: {
search: string;
peers?: Peer[];
isLoading: boolean;
value?: PolicyRuleResource;
onChange: (peer: Peer) => void;
search,
peers,
isLoading,
value,
onChange,
}: {
search: string;
peers?: Peer[];
isLoading: boolean;
value?: PolicyRuleResource;
onChange: (peer: Peer) => void;
}) => {
const [filteredItems, _, setSearch] = useSearch(
peers || [],
peersSearchPredicate,
{ filter: true, debounce: 150 },
);
const [filteredItems, _, setSearch] = useSearch(
peers || [],
peersSearchPredicate,
{ filter: true, debounce: 150 },
);
useEffect(() => {
setSearch(search);
}, [search, setSearch]);
if (isLoading) {
return (
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
</div>
);
}
if (search != "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers matching your search. Please try a different search
term.
</DropdownInfoText>
);
}
if (search == "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers available yet. <br />
Go to <InlineLink href={"/peers"}>Peers</InlineLink> to add some peers.
</DropdownInfoText>
);
}
useEffect(() => {
setSearch(search);
}, [search, setSearch]);
if (isLoading) {
return (
<Radio defaultValue={value?.id} name={"peer"} value={value?.id}>
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
if (!res?.id) return;
return (
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap h-7 px-2",
)}
onClick={(e) => {
e.preventDefault();
}}
>
<PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} />
</Badge>
</div>
<div className={"flex items-center gap-5"}>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{res.ip}
<RadioItem value={res.id} />
</div>
</div>
</Fragment>
);
}}
/>
</Radio>
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
</div>
);
};
}
if (search != "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers matching your search. Please try a different search
term.
</DropdownInfoText>
);
}
if (search == "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers available yet. <br />
Go to <InlineLink href={"/peers"}>Peers</InlineLink> to add some peers.
</DropdownInfoText>
);
}
return (
<Radio defaultValue={value?.id} name={"peer"} value={value?.id}>
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
if (!res?.id) return;
return (
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap h-7 px-2",
)}
onClick={(e) => {
e.preventDefault();
}}
>
<PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} />
</Badge>
</div>
<div className={"flex items-center gap-5"}>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{res.ip}
<RadioItem value={res.id} />
</div>
</div>
</Fragment>
);
}}
/>
</Radio>
);
};

View File

@@ -44,10 +44,12 @@ function Trigger({
children,
value,
disabled = false,
className,
}: {
children: React.ReactNode;
value: string;
disabled?: boolean;
className?: string;
}) {
const currentValue = useTabContext();
return (
@@ -60,6 +62,7 @@ function Trigger({
: disabled
? ""
: "text-nb-gray-400 hover:bg-nb-gray-900/50",
className,
)}
value={value}
>

View File

@@ -37,6 +37,10 @@ interface SelectDropdownProps {
searchPlaceholder?: string;
isLoading?: boolean;
variant?: ButtonVariants["variant"];
className?: string;
size?: "xs" | "sm";
children?: React.ReactNode;
maxHeight?: number;
}
export function SelectDropdown({
@@ -51,6 +55,10 @@ export function SelectDropdown({
searchPlaceholder = "Search...",
isLoading = false,
variant = "input",
className,
size = "sm",
children,
maxHeight,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -79,6 +87,46 @@ export function SelectDropdown({
});
}, [options, debouncedSearch]);
const Loading = () => {
return (
<div className={"flex items-center gap-2"}>
<Skeleton width={20} />
<Skeleton width={100} />
</div>
);
};
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
</div>
</div>
);
};
const PlaceholderItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
)}
>
<span className={"text-nb-gray-200"}>{placeholder}</span>
</div>
</div>
);
};
return (
<Popover
open={open}
@@ -91,45 +139,26 @@ export function SelectDropdown({
setOpen(isOpen);
}}
>
<PopoverTrigger asChild={true} disabled={disabled || isLoading}>
<Button
variant={variant}
disabled={disabled || isLoading}
ref={inputRef}
className={"w-full"}
>
<div className={"w-full flex justify-between items-center gap-2"}>
{isLoading ? (
<div className={"flex gap-2"}>
<Skeleton width={20} />
<Skeleton width={100} />
<PopoverTrigger asChild={!children} disabled={disabled || isLoading}>
{children ? (
children
) : (
<Button
variant={variant}
disabled={disabled || isLoading}
ref={inputRef}
className={cn("w-full", className)}
>
<div className={"w-full flex justify-between items-center gap-2"}>
{isLoading && <Loading />}
{!isLoading && selected && <SelectedItem />}
{!isLoading && !selected && <PlaceholderItem />}
<div className={"pl-2"}>
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
) : selected ? (
<React.Fragment>
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200"}>
{selected?.label}
</span>
</div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<div className={"flex items-center gap-2.5"}>
<div className={"flex flex-col text-sm font-medium"}>
<span className={"text-nb-gray-200"}>{placeholder}</span>
</div>
</div>
</React.Fragment>
)}
<div className={"pl-2"}>
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</div>
</Button>
</Button>
)}
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
@@ -164,18 +193,22 @@ export function SelectDropdown({
<ScrollArea
className={cn(
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 pb-2 pr-3",
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1"}>
<div className={"grid grid-cols-1 gap-1 pb-2"}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
showValue={showValues}
size={size}
/>
))}
</div>
@@ -192,10 +225,12 @@ const SelectDropdownItem = ({
option,
toggle,
showValue = false,
size = "sm",
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -221,13 +256,20 @@ const SelectDropdownItem = ({
>
<div className={"flex items-center gap-2.5 p-1"}>
{option.icon && <option.icon size={14} width={14} />}
<div className={"flex flex-col text-sm font-medium"}>
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
)}
>
<span className={"text-nb-gray-200"}>{option.label}</span>
</div>
</div>
{showValue && (
<div className={"flex items-center gap-2.5 p-1"}>
<Paragraph className={cn("text-sm text-right")}>
<Paragraph
className={cn("text-sm text-right", size === "xs" && "text-xs")}
>
{option.value}
</Paragraph>
</div>

View File

@@ -1,4 +1,3 @@
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { SearchIcon } from "lucide-react";
import * as React from "react";
@@ -38,15 +37,9 @@ export const SelectDropdownSearchInput = forwardRef<HTMLInputElement, Props>(
<SearchIcon size={14} />
</div>
</div>
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
<div
className={"absolute right-0 top-0 h-full flex items-center pr-4"}
></div>
</div>
);
},

View File

@@ -10,6 +10,7 @@ type Props = {
description?: string;
button?: React.ReactNode;
learnMore?: React.ReactNode;
showBackground?: boolean;
};
export default function GetStartedTest({
@@ -18,28 +19,33 @@ export default function GetStartedTest({
description,
button,
learnMore,
showBackground = true,
}: Props) {
return (
<div className={"px-8 mt-8"}>
<Card className={"w-full relative overflow-hidden"}>
<div
className={
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
}
></div>
<div
className={
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
}
>
<div className={"flex flex-col gap-2"}>
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
</div>
</div>
{showBackground && (
<>
<div
className={
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
}
></div>
<div
className={
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
}
>
<div className={"flex flex-col gap-2"}>
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
<Skeleton className={"w-full"} height={70} duration={4} />
</div>
</div>
</>
)}
<div className={"w-full h-full z-20 relative left-0 top-0 flex py-8"}>
<div className={"inline-flex justify-center w-full"}>
<div>

View File

@@ -11,9 +11,11 @@ import { useGroupIdentification } from "@/modules/groups/useGroupIdentification"
export const GroupBadgeIcon = ({
id,
issued,
size = 12,
}: {
id?: string;
issued?: GroupIssued;
size?: number;
}) => {
const { groups } = useGroups();
const group = groups?.find((g) => g.id === id);
@@ -22,11 +24,12 @@ export const GroupBadgeIcon = ({
useGroupIdentification({ id, issued: issued ?? group?.issued });
if (isGoogleGroup)
return <GoogleIcon size={11} className={"shrink-0 mr-0.5"} />;
return <GoogleIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
if (isAzureGroup)
return <EntraIcon size={13} className={"shrink-0 mr-0.5"} />;
if (isOktaGroup) return <OktaIcon size={11} className={"shrink-0 mr-0.5"} />;
if (isJWTGroup) return <JWTIcon size={12} className={"shrink-0"} />;
return <EntraIcon size={size + 1} className={"shrink-0 mr-0.5"} />;
if (isOktaGroup)
return <OktaIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
if (isJWTGroup) return <JWTIcon size={size} className={"shrink-0"} />;
return <FolderGit2 size={12} className={"shrink-0"} />;
return <FolderGit2 size={size} className={"shrink-0"} />;
};

View File

@@ -62,7 +62,6 @@ const UserProfileProvider = ({ children }: Props) => {
}
}, [user, error, users, isLoading, isAllUsersLoading]);
const data = useMemo(() => {
return {
loggedInUser,

View File

@@ -23,6 +23,7 @@ export default function useOperatingSystem() {
* Falls back to Linux if the operating system is not recognized
*/
export const getOperatingSystem = (os: string) => {
if (!os) return OperatingSystem.LINUX as const;
if (os.toLowerCase().includes("freebsd"))
return OperatingSystem.FREEBSD as const;
if (os.toLowerCase().includes("darwin"))

View File

@@ -6,6 +6,7 @@ export interface Peer {
name: string;
ip: string;
connected: boolean;
created_at?: Date;
last_seen: Date;
os: string;
version: string;

View File

@@ -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({
>
<div>
<SidebarItemGroup>
<SidebarItem
icon={<ControlCenterIcon size={16} />}
label={
<div className={"flex items-center gap-2"}>
Control Center
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[8px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</div>
}
href={"/control-center"}
visible={permission.policies.read}
/>
<SidebarItem
icon={<PeerIcon />}
label="Peers"

View File

@@ -1,5 +1,6 @@
import Button from "@components/Button";
import ButtonGroup from "@components/ButtonGroup";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
@@ -29,7 +30,6 @@ import AccessControlPortsCell from "@/modules/access-control/table/AccessControl
import AccessControlPostureCheckCell from "@/modules/access-control/table/AccessControlPostureCheckCell";
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
import FullTooltip from "@components/FullTooltip";
type Props = {
policies?: Policy[];
@@ -201,40 +201,40 @@ export default function AccessControlTable({
const [currentRow, setCurrentRow] = useState<Policy>();
const [currentCellClicked, setCurrentCellClicked] = useState("");
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
const withTemporaryPolicies = useCallback(
(condition: boolean) =>
policies?.filter((policy) =>
condition
? policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
: !(
policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
),
) ?? [],
[policies],
);
const withTemporaryPolicies = useCallback(
(condition: boolean) =>
policies?.filter((policy) =>
condition
? policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
: !(
policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
),
) ?? [],
[policies],
);
const tempPolicies = useMemo(
() => withTemporaryPolicies(true),
[withTemporaryPolicies],
);
const regularPolicies = useMemo(
() => withTemporaryPolicies(false),
[withTemporaryPolicies],
);
const tempPolicies = useMemo(
() => withTemporaryPolicies(true),
[withTemporaryPolicies],
);
const regularPolicies = useMemo(
() => withTemporaryPolicies(false),
[withTemporaryPolicies],
);
useEffect(() => {
if (showTemporaryPolicies && tempPolicies?.length === 0) {
setShowTemporaryPolicies(false);
}
}, [showTemporaryPolicies, tempPolicies]);
useEffect(() => {
if (showTemporaryPolicies && tempPolicies?.length === 0) {
setShowTemporaryPolicies(false);
}
}, [showTemporaryPolicies, tempPolicies]);
return (
<>
@@ -338,91 +338,91 @@ export default function AccessControlTable({
</>
)}
>
{(table) => {
return (
<>
<ButtonGroup disabled={policies?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(false);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === false
? "tertiary"
: "secondary"
}
>
Inactive
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={policies?.length == 0}
/>
{(table) => {
return (
<>
<ButtonGroup disabled={policies?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(false);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === false
? "tertiary"
: "secondary"
}
>
Inactive
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={policies?.length == 0}
/>
{tempPolicies?.length > 0 && (
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary policies created by the NetBird browser
client. These policies are ephemeral and will be
deleted automatically after a short period of time.
</div>
}
>
<Button
className={"h-[44px]"}
variant={showTemporaryPolicies ? "tertiary" : "secondary"}
onClick={() => {
setShowTemporaryPolicies(!showTemporaryPolicies);
}}
>
<ClockFadingIcon size={16} />
</Button>
</FullTooltip>
)}
{tempPolicies?.length > 0 && (
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary policies created by the NetBird browser
client. These policies are ephemeral and will be deleted
automatically after a short period of time.
</div>
}
>
<Button
className={"h-[44px]"}
variant={showTemporaryPolicies ? "tertiary" : "secondary"}
onClick={() => {
setShowTemporaryPolicies(!showTemporaryPolicies);
}}
>
<ClockFadingIcon size={16} />
</Button>
</FullTooltip>
)}
<DataTableRefreshButton
isDisabled={policies?.length == 0}
onClick={() => {
mutate("/policies").then();
mutate("/groups").then();
}}
/>
</>
);
}}
<DataTableRefreshButton
isDisabled={policies?.length == 0}
onClick={() => {
mutate("/policies").then();
mutate("/groups").then();
}}
/>
</>
);
}}
</DataTable>
</>
);
}
}

View File

@@ -260,9 +260,9 @@ export const useAccessControl = ({
updatePolicy(
policy,
policyObj,
() => {
(p) => {
mutate("/policies");
onSuccess && onSuccess(policy);
onSuccess && onSuccess(p);
},
"The policy was successfully saved",
);

View File

@@ -677,6 +677,20 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "account.settings.extra.flow.group.remove")
return (
<div className={"inline"}>
Limit traffic event group <Value>{m.group_name}</Value> removed
</div>
);
if (event.activity_code == "account.settings.extra.flow.group.add")
return (
<div className={"inline"}>
Limit traffic event group <Value>{m.group_name}</Value> added
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
<span className={"mb-[1px]"}>{event.activity}</span>

View File

@@ -18,6 +18,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
// Error actions
delete: ActionStatus.ERROR,
revoke: ActionStatus.ERROR,
remove: ActionStatus.ERROR,
block: ActionStatus.ERROR,
reject: ActionStatus.ERROR,

View File

@@ -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 (
<SegmentedTabs value={value} onChange={(v) => onChange?.(v as FlowView)}>
<SegmentedTabs.List
className={
"border-b rounded-b-lg text-sm font-medium bg-nb-gray-930 p-1"
}
>
<SegmentedTabs.Trigger
value={FlowView.PEERS}
className={"text-xs px-3 py-1"}
>
<MonitorSmartphoneIcon size={12} />
Peers
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.GROUPS}
className={"text-xs px-3 py-1"}
>
<FolderGit2 size={12} />
Groups
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.NETWORKS}
className={"text-xs px-3 py-[0.45rem]"}
>
<NetworkIcon size={12} />
Networks
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
);
};

View File

@@ -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<NetworkRouter[]>("/networks/routers");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/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 (
<Button
variant={"secondary"}
size={"xs"}
className={"!bg-nb-gray-930 !text-nb-gray-300 cursor-default"}
>
<CircleIcon
size={8}
className={cn("shrink-0 block", routingPeerStatusColor)}
/>
{network.routers?.length || 0} Routing Peer(s)
</Button>
);
};

View File

@@ -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 (
<>
<line
x1={sx}
y1={sy}
x2={preLabelX}
y2={preLabelY}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
<line
x1={postLabelX}
y1={postLabelY}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
{label && hasLabel && (
<foreignObject
x={labelX - labelWidth / 2}
y={labelY - labelHeight / 2}
width={labelWidth}
height={labelHeight}
style={{ overflow: "visible" }}
>
<div
style={{
width: labelWidth,
height: labelHeight,
fontSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: `${paddingY}px ${paddingX}px`,
transform: `rotate(${angle}deg)`,
transformOrigin: "center center",
boxSizing: "border-box",
background: "none",
}}
className={
"flex items-center justify-center gap-1 select-none pointer-events-none z-10 text-green-50"
}
>
<div className={"whitespace-nowrap"}>{label}</div>
</div>
</foreignObject>
)}
</>
);
}
export default AnimatedLine;

View File

@@ -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 (
<>
<BaseEdge
id={`${id}-forward`}
path={forwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
<BaseEdge
id={`${id}-backward`}
path={backwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
</>
);
}

View File

@@ -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 (
<BaseEdge
id={id}
path={edgePath}
style={{
opacity: enabled ? 1 : 0.6,
strokeWidth: 2,
stroke: enabled ? "#0e9f6e" : "#787878",
strokeDasharray: "5, 5",
}}
>
{enabled && (
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
)}
</BaseEdge>
);
}

View File

@@ -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 (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
);
}
export default FloatingEdge;

View File

@@ -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 (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 1.5,
stroke: "#595959",
strokeDasharray: "0, 0",
opacity: enabled ? 1 : 0.6,
}}
></BaseEdge>
);
}

View File

@@ -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 (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-850 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device && <PeerOSIcon os={device.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
<span
className={
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
}
>
{device?.ip || resource?.address}
</span>
</div>
</div>
);
};
const PeerOSIcon = ({ os }: { os: string }) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};
const ResourceIcon = ({
type,
size = 15,
}: {
type: "domain" | "host" | "subnet";
size?: number;
}) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

@@ -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 (
<div
className={cn(
"cc-group-node bg-nb-gray-940 border border-nb-gray-800 rounded-lg overflow-hidden transition-all",
!enabled && "opacity-60",
hoverable && "hover:bg-nb-gray-930 cursor-pointer",
)}
onClick={() => onClick?.(group)}
>
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={14} />
</div>
<div>
<div className={" text-nb-gray-200 font-normal whitespace-nowrap"}>
{group.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap text-xs"}>
{countLabel}
</div>
</div>
</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -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<NetworkNodeType, "networkNode">;
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 (
<div
className={cn(
"bg-nb-gray-940 border border-nb-gray-800 rounded-2xl overflow-hidden group hover:bg-nb-gray-935 transition-all cursor-pointer",
)}
>
<div
className={cn(
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-6 pr-6 py-3.5 font-normal bg-nb-gray-935 border-b border-nb-gray-800 group-hover:bg-nb-gray-930 transition-all",
resources?.length === 0 && "border-b-0",
)}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div>
<div
className={
" text-nb-gray-100 font-medium whitespace-nowrap flex items-center gap-2"
}
>
<NetworkIcon size={12} />
{n?.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap mt-0.5"}>
{resources?.length || 0} Resources
</div>
</div>
</div>
<div className={"flex items-center gap-2 text-xs"}>
<CircleIcon
size={8}
className={cn(
"shrink-0 block",
routingPeers?.length === 0 && "bg-nb-gray-500",
routingPeers?.length === 1 && "bg-yellow-400",
routingPeers?.length > 1 && "bg-green-400",
)}
/>
{routingPeers?.length || 0} Routing Peer(s)
</div>
</div>
{resources && resources.length > 0 && (
<div className={"p-2 flex flex-col gap-4 relative"}>
<div className={"grid grid-cols-2 relative z-0"}>
{resources?.slice(0, 6).map((r) => {
return <DeviceCard resource={r} key={r.id} />;
})}
</div>
<div
className={cn(
"absolute w-full h-full bg-gradient-to-b from-transparent via-nb-gray-940/20 to-nb-gray-940 z-10 left-0 top-0 pointer-events-none",
resources?.length > 6 ? "opacity-100" : "opacity-0",
)}
></div>
</div>
)}
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
opacity: 0,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};

View File

@@ -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 (
<div
className={
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
device={peer}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -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 (
<div
className={cn(
"relative bg-nb-gray-940 hover:bg-nb-gray-930 cursor-pointer border border-nb-gray-800 rounded-full flex justify-between overflow-hidden",
!isActive && "opacity-60",
)}
>
<div className={"flex items-center justify-center"}>
<div
className={cn(
"h-2 w-2 rounded-full ml-3 mr-2",
isActive ? "bg-green-400" : "bg-nb-gray-400",
)}
></div>
</div>
<div className={"pt-2.5 pb-[0.6rem] pr-3 flex gap-4 leading-none"}>
<div
className={
" text-nb-gray-200 font-normal whitespace-nowrap text-[0.8rem] flex items-center justify-center w-full"
}
>
<div className={"truncate max-w-[200px]"}>{rule?.name}</div>
</div>
</div>
<div
className={
"border-l border-nb-gray-800 flex items-center text-nb-gray-300 text-[0.65rem] pl-2 pr-3 font-mono"
}
>
<div>{label === "" ? "All" : label}</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -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 (
<div
className={
"cursor-pointer border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
resource={resource}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};

View File

@@ -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<Group[]>("/groups");
const groupOptions: SelectOption[] = sortBy(
groups?.map(
(g) =>
({
value: g.id,
label: g.name,
icon: () => (
<GroupBadgeIcon id={g?.id} issued={g?.issued} size={14} />
),
}) 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 (
<div
className={
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<SelectDropdown
variant={"secondary"}
value={data.currentGroup}
onChange={data.onChange}
options={groupOptions}
showSearch={true}
searchPlaceholder={"Search groups..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}
maxHeight={300}
>
<div className={"flex items-center justify-between gap-8 pr-3"}>
{group && (
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon
id={group?.id}
issued={group?.issued}
size={14}
/>
</div>
<div>
<div
className={
" text-nb-gray-200 font-normal whitespace-nowrap text-left"
}
>
{group.name}
</div>
<div
className={
"text-nb-gray-400 whitespace-nowrap text-xs text-left"
}
>
{countLabel}
</div>
</div>
</div>
</div>
)}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -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<Peer[]>("/peers");
const peerSelectOptions: SelectOption[] = sortBy(
peers?.map(
(p) =>
({
value: p.id,
label: p.name,
icon: () => {
const os = p.os as unknown as OperatingSystem;
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
os === OperatingSystem.WINDOWS && "p-[2.5px]",
os === OperatingSystem.APPLE && "p-[2.7px]",
os === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={p.os} />
</div>
);
},
}) as SelectOption,
) || [],
"label",
"asc",
);
const peer = peers?.find((p) => p.id === data.currentPeer);
return (
<div
className={
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<SelectDropdown
variant={"secondary"}
value={data.currentPeer}
onChange={data.onPeerChange}
options={peerSelectOptions}
showSearch={true}
searchPlaceholder={"Search peers..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}
maxHeight={300}
>
<div className={"flex items-center justify-between gap-8 pr-3"}>
{peer && <DeviceCard device={peer} />}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { InternalNode, Node, Position } from "@xyflow/react";
type IntersectionPoint = {
x: number;
y: number;
};
function getNodeIntersection(
intersectionNode: InternalNode<Node>,
targetNode: InternalNode<Node>,
) {
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<Node>,
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<Node>,
target: InternalNode<Node>,
) {
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,
};
}

View File

@@ -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,
};

View File

@@ -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];
}

View File

@@ -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;
});
};

View File

@@ -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,
};

View File

@@ -20,9 +20,9 @@ export const NetworkInformationSquare = ({
return (
<button
className={cn(
"flex w-full items-center max-w-[300px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
"flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
onClick
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-5 relative"
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-14 relative"
: "cursor-default",
)}
onClick={onClick}
@@ -53,7 +53,7 @@ export const NetworkInformationSquare = ({
<div className={"mt-[0px] flex items-center flex-wrap"}>
<p
className={cn(
"font-medium",
"font-medium text-left whitespace-nowrap",
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
)}
>

View File

@@ -1,5 +1,5 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import { cn } from "@utils/helpers";
import { ListItem } from "@components/ListItem";
import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
@@ -104,30 +104,3 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
</div>
);
};
const ListItem = ({
icon,
label,
value,
className,
}: {
icon: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};

View File

@@ -50,7 +50,7 @@ export const PeerConnectButton = () => {
<FullTooltip
content={
<div className={"max-w-[200px] text-xs"}>
Connecting via SSH or RDP is only available when the peer is online.
Connecting via SSH or RDP is only available when the peer is online.
</div>
}
>

View File

@@ -42,14 +42,14 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
active={peer.connected}
text={peer.name}
additionalInfo={
isOwnerOrAdmin && (
<>
<ExitNodePeerIndicator peer={peer} />
<EphemeralPeerIndicator peer={peer} />
<ExpirationDisabledIndicator peer={peer} />
<LoginRequiredIndicator peer={peer} />
</>
)
isOwnerOrAdmin && (
<>
<ExitNodePeerIndicator peer={peer} />
<EphemeralPeerIndicator peer={peer} />
<ExpirationDisabledIndicator peer={peer} />
<LoginRequiredIndicator peer={peer} />
</>
)
}
>
<div className={"text-nb-gray-400 font-light truncate"}>

View File

@@ -1,3 +1,4 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import {
Tooltip,
@@ -8,13 +9,13 @@ import {
import MemoizedNetBirdIcon from "@components/ui/MemoizedNetBirdIcon";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { parseVersionString } from "@utils/version";
import { trim } from "lodash";
import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import FullTooltip from "@components/FullTooltip";
type Props = {
version: string;
@@ -38,6 +39,8 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;
}, []);
const isWasmClient = trim(os) === "js";
return (
<div className={"flex flex-col gap-1"}>
{updateAvailable ? (
@@ -111,7 +114,7 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
>
<PeerOperatingSystemIcon os={os} />
{os}
{isWasmClient ? "Web Client" : os}
</div>
</FullTooltip>
)}

View File

@@ -1,26 +1,24 @@
import Button from "@components/Button";
import ButtonGroup from "@components/ButtonGroup";
import { Checkbox } from "@components/Checkbox";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import FullTooltip from "@components/FullTooltip";
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import AddPeerButton from "@components/ui/AddPeerButton";
import GetStartedTest from "@components/ui/GetStartedTest";
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
import {
ColumnDef,
RowSelectionState,
SortingState,
} from "@tanstack/react-table";
import { uniqBy } from "lodash";
import { ExternalLinkIcon } from "lucide-react";
import { trim, uniqBy } from "lodash";
import { MonitorDotIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeerProvider from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
@@ -30,6 +28,7 @@ import { Peer } from "@/interfaces/Peer";
import { GroupFilterSelector } from "@/modules/groups/GroupFilterSelector";
import PeerActionCell from "@/modules/peers/PeerActionCell";
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
import { PeerConnectButton } from "@/modules/peers/PeerConnectButton";
import PeerGroupCell from "@/modules/peers/PeerGroupCell";
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
import { PeerMultiSelect } from "@/modules/peers/PeerMultiSelect";
@@ -37,9 +36,6 @@ import PeerNameCell from "@/modules/peers/PeerNameCell";
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
import PeerStatusCell from "@/modules/peers/PeerStatusCell";
import PeerVersionCell from "@/modules/peers/PeerVersionCell";
import FullTooltip from "@components/FullTooltip";
import { MonitorDotIcon } from "lucide-react";
import { PeerConnectButton } from "@/modules/peers/PeerConnectButton";
const PeersTableColumns: ColumnDef<Peer>[] = [
{
@@ -76,14 +72,14 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
cell: ({ row }) => <PeerNameCell peer={row.original} />,
},
{
id: "connect",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerConnectButton />
</PeerProvider>
),
id: "connect",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerConnectButton />
</PeerProvider>
),
},
{
id: "approval_required",
@@ -170,11 +166,11 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
return <DataTableHeader column={column}>Version</DataTableHeader>;
},
cell: ({ row }) => (
<PeerVersionCell
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
/>
<PeerVersionCell
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
/>
),
},
{
@@ -260,31 +256,29 @@ export default function PeersTable({
}
};
const [showBrowserPeers, setShowBrowserPeers] = useState(false);
const [showBrowserPeers, setShowBrowserPeers] = useState(false);
const withBrowserPeers = useCallback(
(condition: boolean) =>
peers?.filter((peer) =>
condition
? peer.kernel_version === "wasm"
: peer.kernel_version !== "wasm",
) ?? [],
[peers],
);
const withBrowserPeers = useCallback(
(condition: boolean) =>
peers?.filter((peer) =>
condition ? trim(peer.os) === "js" : trim(peer.os) !== "js",
) ?? [],
[peers],
);
const browserPeers = useMemo(() => {
return withBrowserPeers(true);
}, [withBrowserPeers]);
const browserPeers = useMemo(() => {
return withBrowserPeers(true);
}, [withBrowserPeers]);
const regularPeers = useMemo(() => {
return withBrowserPeers(false);
}, [withBrowserPeers]);
const regularPeers = useMemo(() => {
return withBrowserPeers(false);
}, [withBrowserPeers]);
useEffect(() => {
if (showBrowserPeers && browserPeers?.length === 0) {
setShowBrowserPeers(false);
}
}, [showBrowserPeers, browserPeers]);
useEffect(() => {
if (showBrowserPeers && browserPeers?.length === 0) {
setShowBrowserPeers(false);
}
}, [showBrowserPeers, browserPeers]);
return (
<>
@@ -319,35 +313,7 @@ export default function PeersTable({
os: false,
}}
isLoading={isLoading}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Get Started with NetBird"}
description={
"It looks like you don't have any connected machines.\n" +
"Get started by adding one to your network."
}
button={<AddPeerButton />}
learnMore={
<>
Learn more in our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started"}
target={"_blank"}
>
Getting Started Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
getStartedCard={<NoPeersGettingStarted showBackground={true} />}
rightSide={() => <>{peers && peers.length > 0 && <AddPeerButton />}</>}
>
{(table) => (
@@ -516,27 +482,27 @@ export default function PeersTable({
/>
)}
{browserPeers?.length > 0 && (
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary peers created by the NetBird browser client.
These peers are ephemeral and will be deleted automatically
after a short period of time.
</div>
}
>
<Button
className={"h-[44px]"}
variant={showBrowserPeers ? "tertiary" : "secondary"}
onClick={() => {
setShowBrowserPeers(!showBrowserPeers);
}}
>
<MonitorDotIcon size={16} />
</Button>
</FullTooltip>
)}
{browserPeers?.length > 0 && (
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary peers created by the NetBird browser client.
These peers are ephemeral and will be deleted automatically
after a short period of time.
</div>
}
>
<Button
className={"h-[44px]"}
variant={showBrowserPeers ? "tertiary" : "secondary"}
onClick={() => {
setShowBrowserPeers(!showBrowserPeers);
}}
>
<MonitorDotIcon size={16} />
</Button>
</FullTooltip>
)}
<DataTableRefreshButton
isDisabled={peers?.length == 0}

View File

@@ -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);

View File

@@ -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 = ({
</div>
<div>
Learn more about{" "}
<InlineLink href={SSH_DOCS_LINK} target={"_blank"}>
<InlineLink
href={"https://docs.netbird.io/how-to/ssh"}
target={"_blank"}
>
SSH <ExternalLinkIcon size={12} />
</InlineLink>
</div>

View File

@@ -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);

View File

@@ -1,19 +1,19 @@
import { useApiCall } from "@utils/api";
import loadConfig from "@utils/config";
import { getBrowserInfo } from "@utils/helpers";
import { generateKeypair } from "@utils/wireguard";
import { trim } from "lodash";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { IronRDPInputHandler } from "@/modules/remote-access/rdp/ironrdp-input-handler";
import { IronRDPWASMBridge } from "@/modules/remote-access/rdp/ironrdp-wasm-bridge";
import { RDPCertificateHandler } from "@/modules/remote-access/rdp/rdp-certificate-handler";
import { installWebSocketProxy } from "@/modules/remote-access/rdp/websocket-proxy";
import { useApiCall } from "@utils/api";
import { generateKeypair } from "@utils/wireguard";
import { getBrowserInfo } from "@utils/helpers";
import { trim } from "lodash";
const config = loadConfig();
const WASM_CONFIG = {
SCRIPT_PATH: "/wasm_exec.js",
WASM_PATH: "/netbird.wasm",
WASM_PATH: "https://pkgs.netbird.io/wasm/client",
INIT_TIMEOUT: 10000,
RETRY_DELAY: 100,
} as const;

View File

@@ -0,0 +1,52 @@
import Button from "@components/Button";
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
import { Table } from "@tanstack/react-table";
import * as React from "react";
import { useEffect } from "react";
type Props<T> = {
table: Table<T>;
data?: T[];
count?: number;
};
export const PendingApprovalFilter = <T,>({ table, data, count }: Props<T>) => {
// Reset filter if there are no pending approvals
useEffect(() => {
if (
count == 0 &&
table.getColumn("approval_required")?.getFilterValue() === true
) {
table.setColumnFilters([]);
}
}, [count, table]);
if (!count) return;
return (
<Button
disabled={data?.length == 0}
onClick={() => {
table.setPageIndex(0);
let current =
table.getColumn("approval_required")?.getFilterValue() === undefined
? true
: undefined;
table.setColumnFilters([
{
id: "approval_required",
value: current,
},
]);
}}
variant={
table.getColumn("approval_required")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
Pending Approvals
<NotificationCountBadge count={count} />
</Button>
);
};

View File

@@ -20,6 +20,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { User } from "@/interfaces/User";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter";
import UserActionCell from "@/modules/users/table-cells/UserActionCell";
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
import UserGroupCell from "@/modules/users/table-cells/UserGroupCell";
@@ -134,8 +135,6 @@ export default function UsersTable({
);
const router = useRouter();
const pendingApprovalCount =
users?.filter((u) => u.pending_approval).length || 0;
return (
<DataTable
@@ -196,44 +195,13 @@ export default function UsersTable({
)}
>
{(table) => {
if (
pendingApprovalCount == 0 &&
table.getColumn("approval_required")?.getFilterValue() === true
) {
table.setColumnFilters([]);
}
return (
<>
{pendingApprovalCount > 0 && (
<Button
disabled={users?.length == 0}
onClick={() => {
table.setPageIndex(0);
let current =
table.getColumn("approval_required")?.getFilterValue() ===
undefined
? true
: undefined;
table.setColumnFilters([
{
id: "approval_required",
value: current,
},
]);
}}
variant={
table.getColumn("approval_required")?.getFilterValue() ===
true
? "tertiary"
: "secondary"
}
>
Pending Approvals
<NotificationCountBadge count={pendingApprovalCount} />
</Button>
)}
<PendingApprovalFilter
table={table}
data={users}
count={users?.filter((u) => u?.pending_approval)?.length}
/>
<DataTableRowsPerPage table={table} disabled={users?.length == 0} />
<DataTableRefreshButton
isDisabled={users?.length == 0}

View File

@@ -215,7 +215,7 @@ export function useApiErrorHandling(ignoreError = false) {
const { login } = useOidc();
const currentPath = usePathname();
const { setError } = useErrorBoundary();
if (ignoreError)
return (err: ErrorResponse) => {
console.log(err);
@@ -232,21 +232,22 @@ export function useApiErrorHandling(ignoreError = false) {
if (err.code == 401 && err.message == "token invalid") {
setError(err);
}
// Handle user blocked/pending approval responses
if (err.code == 403 && (
err.message?.toLowerCase().includes("blocked") ||
err.message?.toLowerCase().includes("pending")
)) {
if (
err.code == 403 &&
(err.message?.toLowerCase().includes("blocked") ||
err.message?.toLowerCase().includes("pending"))
) {
const params = new URLSearchParams({
code: err.code.toString(),
message: encodeURIComponent(err.message),
type: "user-status"
type: "user-status",
});
window.location.href = `/error?${params.toString()}`;
return Promise.reject(err);
}
if (err.code == 500 && err.message == "internal server error") {
setError(err);
}

View File

@@ -28,6 +28,7 @@ const config: Config = {
"920": "#25282d",
"925": "#1e2123",
"930": "#25282c",
"935": "#1f2124",
"940": "#1c1d21",
"950": "#181a1d",
},