Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936de0f4f3 | ||
|
|
d81b75a946 | ||
|
|
a632eeeef0 | ||
|
|
e2219aeea0 | ||
|
|
63f4c69eb4 | ||
|
|
b1af256296 | ||
|
|
4027894a2e | ||
|
|
af90792595 | ||
|
|
9a401733b3 | ||
|
|
07b6895380 | ||
|
|
9e2e38764e | ||
|
|
d9fb379abf | ||
|
|
831673d0d6 | ||
|
|
bc4aac10aa |
30
.github/workflows/build_and_push.yml
vendored
30
.github/workflows/build_and_push.yml
vendored
@@ -28,6 +28,33 @@ jobs:
|
||||
|
||||
- run: echo '{}' > .local-config.json
|
||||
|
||||
- name: Download IronRDP release TS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release JS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release WASM file
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
-
|
||||
@@ -44,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 }}
|
||||
@@ -55,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 }}
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,3 +41,8 @@ next-env.d.ts
|
||||
.configs/.staging-config.json
|
||||
.configs/.temp-config.json
|
||||
.configs
|
||||
|
||||
/public/ironrdp-pkg/
|
||||
/public/netbird.wasm
|
||||
.idea
|
||||
src/.local-config*
|
||||
@@ -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
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
|
||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
||||
}
|
||||
@@ -3,6 +3,14 @@ server {
|
||||
listen [::]:80 default_server;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
location = /netbird.wasm {
|
||||
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;
|
||||
|
||||
@@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
@@ -101,6 +101,7 @@ http {
|
||||
application/rss+xml
|
||||
application/vnd.geo+json
|
||||
application/vnd.ms-fontobject
|
||||
application/wasm
|
||||
application/x-font-ttf
|
||||
application/x-web-app-manifest+json
|
||||
application/xhtml+xml
|
||||
|
||||
841
package-lock.json
generated
841
package-lock.json
generated
@@ -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,11 +31,15 @@
|
||||
"@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",
|
||||
"@types/react-dom": "^18",
|
||||
"@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",
|
||||
@@ -42,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",
|
||||
@@ -53,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",
|
||||
@@ -199,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",
|
||||
@@ -2586,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",
|
||||
@@ -2894,6 +3178,53 @@
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
|
||||
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"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",
|
||||
@@ -3707,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",
|
||||
@@ -4192,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",
|
||||
@@ -4288,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",
|
||||
@@ -4383,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",
|
||||
@@ -5744,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",
|
||||
@@ -5843,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",
|
||||
@@ -6627,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"
|
||||
@@ -7819,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",
|
||||
@@ -7841,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",
|
||||
@@ -7907,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": {
|
||||
@@ -8888,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": {
|
||||
@@ -9084,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^7.22.18",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -35,11 +36,15 @@
|
||||
"@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",
|
||||
"@types/react-dom": "^18",
|
||||
"@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",
|
||||
@@ -47,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",
|
||||
@@ -58,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",
|
||||
@@ -84,7 +91,6 @@
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"cypress": "^13.13.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3"
|
||||
|
||||
575
public/wasm_exec.js
Normal file
575
public/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -33,7 +33,7 @@ export default function AccessControlPage() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/policies"}
|
||||
href={"/access-control"}
|
||||
label={"Access Control"}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
|
||||
8
src/app/(dashboard)/control-center/layout.tsx
Normal file
8
src/app/(dashboard)/control-center/layout.tsx
Normal 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;
|
||||
1290
src/app/(dashboard)/control-center/page.tsx
Normal file
1290
src/app/(dashboard)/control-center/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ export default function NameServers() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns"}
|
||||
href={"/dns/nameservers"}
|
||||
label={"DNS"}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
|
||||
8
src/app/(dashboard)/group/layout.tsx
Normal file
8
src/app/(dashboard)/group/layout.tsx
Normal 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: `Group - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
297
src/app/(dashboard)/group/page.tsx
Normal file
297
src/app/(dashboard)/group/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { GroupProvider, useGroupContext } from "@/contexts/GroupProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
|
||||
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
|
||||
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
|
||||
import { GroupPoliciesSection } from "@/modules/groups/details/GroupPoliciesSection";
|
||||
import { GroupResourcesSection } from "@/modules/groups/details/GroupResourcesSection";
|
||||
import { GroupSetupKeysSection } from "@/modules/groups/details/GroupSetupKeysSection";
|
||||
import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
|
||||
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
|
||||
|
||||
export default function GroupPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
const { isRestricted } = usePermissions();
|
||||
const groupId = queryParameter.get("id");
|
||||
const {
|
||||
data: group,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Group>(`/groups/${groupId}`, true);
|
||||
|
||||
useRedirect("/groups", false, !groupId || isRestricted);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RestrictedAccess page={"Group Information"} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<PageNotFound
|
||||
title={error?.message}
|
||||
description={
|
||||
"The group you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return group && !isLoading ? (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<GroupProvider group={group} isDetailPage={true}>
|
||||
<div className={"p-default py-6 pb-0 w-full mb-[6px]"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={"Groups"}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={group.name} active />
|
||||
</Breadcrumbs>
|
||||
<GroupDetailsName />
|
||||
</div>
|
||||
<GroupOverviewTabs group={group} />
|
||||
</GroupProvider>
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
const GroupDetailsName = () => {
|
||||
const { group, isJWTGroup, isAllowedToRename, openGroupRenameModal } =
|
||||
useGroupContext();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<h1 className={"flex items-center gap-3 w-full whitespace-nowrap"}>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={20} />
|
||||
{group.name}
|
||||
{group.name !== "All" && permission?.groups?.update && (
|
||||
<div>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{isJWTGroup
|
||||
? GROUP_TOOLTIP_TEXT.RENAME.JWT
|
||||
: GROUP_TOOLTIP_TEXT.RENAME.INTEGRATION}
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
disabled={isAllowedToRename}
|
||||
className={"w-full block"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
|
||||
!isAllowedToRename &&
|
||||
"opacity-40 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
onClick={openGroupRenameModal}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</div>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const validAllGroupTabs = [
|
||||
"policies",
|
||||
"resources",
|
||||
"network-routes",
|
||||
"nameservers",
|
||||
];
|
||||
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
|
||||
|
||||
const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getInitialTab = () => {
|
||||
const isAllGroup = group.name === "All";
|
||||
const tabParam = searchParams.get("tab");
|
||||
const validTabs = isAllGroup
|
||||
? validAllGroupTabs
|
||||
: [...validAllGroupTabs, ...validOtherGroupTabs];
|
||||
if (tabParam === null) return isAllGroup ? "policies" : "users";
|
||||
if (isAllGroup) {
|
||||
return validTabs.includes(tabParam) ? tabParam : "policies";
|
||||
}
|
||||
return validTabs.includes(tabParam) ? tabParam : "users";
|
||||
};
|
||||
|
||||
const [tab, setTab] = useState(getInitialTab());
|
||||
const groupDetails = useGroupDetails(group?.id || "");
|
||||
|
||||
const peersCount = groupDetails?.peers_count || 0;
|
||||
const usersCount = groupDetails?.users?.length || 0;
|
||||
const policiesCount = groupDetails?.policies?.length || 0;
|
||||
const resourcesCount = groupDetails?.resources_count || 0;
|
||||
const routesCount = groupDetails?.routes?.length || 0;
|
||||
const nameserversCount = groupDetails?.nameservers?.length || 0;
|
||||
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={(v) => setTab(v)}
|
||||
value={tab}
|
||||
className={"pt-2 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"users"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<TeamIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Users", usersCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"peers"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Peers", peersCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
<TabsTrigger
|
||||
value={"policies"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<AccessControlIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Policies", policiesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", resourcesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"network-routes"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<NetworkRoutesIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Network Routes", routesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value={"nameservers"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<DNSIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Nameservers", nameserversCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
{group.name !== "All" && (
|
||||
<TabsTrigger
|
||||
value={"setup-keys"}
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<SetupKeysIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Setup Keys", setupKeysCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"users"} className={"pb-8"}>
|
||||
<GroupUsersSection users={groupDetails?.users} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"peers"} className={"pb-8"}>
|
||||
<GroupPeersSection peers={groupDetails?.peersOfGroup} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"policies"} className={"pb-8"}>
|
||||
<GroupPoliciesSection policies={groupDetails?.policies} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<GroupResourcesSection resources={groupDetails?.networkResources} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<GroupNetworkRoutesSection routes={groupDetails?.routes} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"nameservers"} className={"pb-8"}>
|
||||
<GroupNameserversSection nameserverGroups={groupDetails?.nameservers} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"setup-keys"} className={"pb-8"}>
|
||||
<GroupSetupKeysSection setupKeys={groupDetails?.setupKeys} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
8
src/app/(dashboard)/groups/layout.tsx
Normal file
8
src/app/(dashboard)/groups/layout.tsx
Normal 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: `Groups - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
56
src/app/(dashboard)/groups/page.tsx
Normal file
56
src/app/(dashboard)/groups/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import InlineLink from "@/components/InlineLink";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
|
||||
|
||||
export default function GroupsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={"Groups"}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Groups</h1>
|
||||
<Paragraph>
|
||||
Here is the overview of the groups of your organization. You can
|
||||
delete the unused ones.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Groups
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<GroupsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import { Callout } from "@components/Callout";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
@@ -59,6 +60,17 @@ export default function NetworkRoutes() {
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
|
||||
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
|
||||
<span>
|
||||
We recommend using the new Networks concept to easier visualise
|
||||
and manage access to your resources.{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={14} />
|
||||
</InlineLink>
|
||||
</span>
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess hasAccess={permission.routes.read}>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Separator from "@components/Separator";
|
||||
@@ -12,12 +20,14 @@ import { cn } from "@utils/helpers";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
HelpCircle,
|
||||
MoreVertical,
|
||||
PencilLineIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
@@ -25,8 +35,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import NetworkModal from "@/modules/networks/NetworkModal";
|
||||
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
} from "@/modules/networks/NetworkProvider";
|
||||
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
|
||||
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
|
||||
|
||||
@@ -77,35 +89,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
{permission.networks.update && (
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setNetworkModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
onUpdated={() => {
|
||||
mutate(`/networks/${network.id}`);
|
||||
}}
|
||||
network={network}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkProvider network={network}>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +125,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkActions() {
|
||||
const { permission } = usePermissions();
|
||||
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
|
||||
const router = useRouter();
|
||||
|
||||
if (!network) return;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button variant={"secondary"} className={"!px-3"}>
|
||||
<MoreVertical size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto" align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => openEditNetworkModal(network)}
|
||||
disabled={!permission.networks.update}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<PencilLineIcon size={14} className={"shrink-0"} />
|
||||
Rename
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
deleteNetwork(network).then(() => router.push("/networks"))
|
||||
}
|
||||
variant={"danger"}
|
||||
disabled={!permission.networks.delete}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Delete
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
const isHighlyAvailable = !!(
|
||||
network?.routing_peers_count && network?.routing_peers_count >= 2
|
||||
@@ -154,7 +205,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
const policyCount = network.policies?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={"w-full lg:w-1/2"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
|
||||
@@ -4,8 +4,6 @@ import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
@@ -33,16 +31,15 @@ import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
History,
|
||||
LockIcon,
|
||||
MapPin,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
TerminalSquare,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -65,6 +62,9 @@ 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 { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -80,9 +80,8 @@ export default function PeerPage() {
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let ssh = peer?.ssh_enabled ? "1" : "0";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${ssh}-${expiration}`;
|
||||
return `${id}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
if (isRestricted) {
|
||||
@@ -104,7 +103,7 @@ export default function PeerPage() {
|
||||
);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId}>
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peerKey} />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
@@ -138,8 +137,7 @@ function PeerOverview() {
|
||||
const PeerGeneralInformation = () => {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
|
||||
const [ssh, setSsh] = useState(peer.ssh_enabled);
|
||||
const { peer, user, peerGroups, update } = usePeer();
|
||||
const [name, setName] = useState(peer.name);
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
@@ -158,7 +156,6 @@ const PeerGeneralInformation = () => {
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
@@ -171,7 +168,6 @@ const PeerGeneralInformation = () => {
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
@@ -187,7 +183,6 @@ const PeerGeneralInformation = () => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
@@ -311,41 +306,17 @@ const PeerGeneralInformation = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={permission.peers.update}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={ssh}
|
||||
disabled={!permission.peers.update}
|
||||
onChange={(set) =>
|
||||
!set
|
||||
? setSsh(false)
|
||||
: openSSHDialog().then((confirm) => setSsh(confirm))
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<TerminalSquare size={16} />
|
||||
SSH Access
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable the SSH server on this peer to access the machine via an secure shell."
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
<PeerSSHToggle />
|
||||
|
||||
{/* 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>
|
||||
@@ -569,6 +540,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={
|
||||
<>
|
||||
|
||||
@@ -19,9 +19,9 @@ import { useAccount } from "@/modules/account/useAccount";
|
||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import GroupsTab from "@/modules/settings/GroupsTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||
|
||||
export default function NetBirdSettings() {
|
||||
const queryParams = useSearchParams();
|
||||
@@ -81,7 +81,7 @@ export default function NetBirdSettings() {
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsTab account={account} />}
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
|
||||
9
src/app/(remote-access)/layout.tsx
Normal file
9
src/app/(remote-access)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<UsersProvider>{children}</UsersProvider>
|
||||
);
|
||||
}
|
||||
213
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
213
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
|
||||
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
|
||||
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
|
||||
import {
|
||||
RDPCredentials,
|
||||
RDPStatus,
|
||||
useRemoteDesktop,
|
||||
} from "@/modules/remote-access/rdp/useRemoteDesktop";
|
||||
import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
|
||||
{peerId && peer && !isLoading ? (
|
||||
<RDPSession key={peer.id} peer={peer} />
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
function RDPSession({ peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
|
||||
const rdp = useRemoteDesktop(client);
|
||||
const [credentialsModal, setCredentialsModal] = useState(true);
|
||||
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
|
||||
const connected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${peer.name} - ${peer.ip} - RDP`;
|
||||
}, [peer.ip, peer.name, connected, rdp]);
|
||||
|
||||
const sendErrorNotification = (title: string, message: string) => {
|
||||
notify({
|
||||
title: title,
|
||||
description: message,
|
||||
icon: <IconCircleX size={24} />,
|
||||
backgroundColor: "bg-red-500",
|
||||
duration: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
setCredentials(null);
|
||||
connected.current = false;
|
||||
setCredentialsModal(true);
|
||||
rdp.session?.disconnect();
|
||||
await client.disconnect();
|
||||
}, [client, rdp]);
|
||||
|
||||
/**
|
||||
* Establishes a connection to the peer
|
||||
*/
|
||||
const connect = async (rdpCredentials: RDPCredentials) => {
|
||||
if (!peer?.id) return;
|
||||
if (client.status === NetBirdStatus.DISCONNECTED) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
"NetBird Connection Error",
|
||||
(error as Error).message,
|
||||
);
|
||||
setIsNetBirdConnecting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
if (!credentials) return;
|
||||
try {
|
||||
const result = await rdp.connect({
|
||||
hostname: peer.ip,
|
||||
port: credentials.port,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
domain: credentials.domain,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
if (result === RDPStatus.CONNECTED) {
|
||||
connected.current = true;
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
sendErrorNotification("RDP Connection Error", (error as Error).message);
|
||||
setCredentialsModal(true);
|
||||
await reset();
|
||||
}
|
||||
}, [credentials, peer.ip, rdp, reset]);
|
||||
|
||||
/**
|
||||
* Establish RDP session when NetBird connection is ready
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
client.status === NetBirdStatus.CONNECTED &&
|
||||
rdp.status === RDPStatus.DISCONNECTED &&
|
||||
credentials &&
|
||||
!connected.current &&
|
||||
!isNetBirdConnecting
|
||||
) {
|
||||
startSession().catch(console.error);
|
||||
}
|
||||
}, [
|
||||
client.status,
|
||||
credentials,
|
||||
peer.ip,
|
||||
rdp,
|
||||
startSession,
|
||||
isNetBirdConnecting,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display notifications for RDP and NetBird client errors
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rdp.error) {
|
||||
sendErrorNotification("RDP Error", rdp.error);
|
||||
}
|
||||
if (client.error) {
|
||||
sendErrorNotification("NetBird Client Error", client.error);
|
||||
}
|
||||
}, [rdp, client]);
|
||||
|
||||
/**
|
||||
* Close credentials modal when RDP is connected
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rdp.status === RDPStatus.CONNECTED) {
|
||||
setCredentialsModal(false);
|
||||
}
|
||||
}, [rdp.status]);
|
||||
|
||||
const isLoading =
|
||||
client.status === NetBirdStatus.CONNECTING ||
|
||||
rdp.status === RDPStatus.CONNECTING ||
|
||||
rdp.isResizing ||
|
||||
isNetBirdConnecting;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Credentials Modal */}
|
||||
<RDPCredentialsModal
|
||||
open={credentialsModal}
|
||||
peer={peer}
|
||||
onConnect={connect}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Certificate Modal */}
|
||||
<RDPCertificateModal
|
||||
open={!!rdp.pendingCertificate}
|
||||
certificateInfo={rdp.pendingCertificate}
|
||||
onAccept={rdp.acceptCertificatePrompt}
|
||||
onReject={async () => {
|
||||
rdp.rejectCertificatePrompt();
|
||||
await reset();
|
||||
}}
|
||||
/>
|
||||
|
||||
{rdp.isResizing && (
|
||||
<div
|
||||
className={
|
||||
"fixed w-screen h-screen z-50 backdrop-blur bg-black/50 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
<Loader2Icon size={20} className={"animate-spin"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RDP Canvas */}
|
||||
<canvas
|
||||
ref={rdp.canvasRef}
|
||||
className={cn(
|
||||
rdp.status === RDPStatus.CONNECTED ? "block" : "hidden",
|
||||
"w-full h-full select-none bg-nb-gray-950",
|
||||
)}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
236
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
|
||||
import { SSHStatus, useSSH } from "@/modules/remote-access/ssh/useSSH";
|
||||
import { useSSHQueryParams } from "@/modules/remote-access/ssh/useSSHQueryParams";
|
||||
import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
<ErrorMessage
|
||||
error={{
|
||||
message:
|
||||
"This peer may have been deleted, or you may not have permission to view it.",
|
||||
code: error.code,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
{peerId && peer && !isLoading && username && port ? (
|
||||
<SSHTerminal
|
||||
key={peer.id}
|
||||
peer={peer}
|
||||
username={username}
|
||||
port={port}
|
||||
/>
|
||||
) : (
|
||||
<LoadingMessage message={"Starting ssh session..."} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
username: string;
|
||||
port: string;
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
function SSHTerminal({ username, port, peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const connected = useRef(false);
|
||||
const sshConnectedOnce = useRef(false);
|
||||
|
||||
const {
|
||||
connect: ssh,
|
||||
disconnect,
|
||||
status,
|
||||
session,
|
||||
error: sshError,
|
||||
} = useSSH(client);
|
||||
|
||||
const isSSHConnecting = status === SSHStatus.CONNECTING;
|
||||
const isSSHConnected = status === SSHStatus.CONNECTED;
|
||||
const isSSHDisconnected = status === SSHStatus.DISCONNECTED;
|
||||
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
|
||||
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
|
||||
}, [username, peer, client]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
if (!peer?.id) return;
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
connected.current = false;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
port: Number(port),
|
||||
username,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reconnection failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
if (isClientConnecting || client.status === NetBirdStatus.CONNECTED) return;
|
||||
|
||||
const connect = async () => {
|
||||
if (!peer.id) return;
|
||||
if (connected.current) return;
|
||||
connected.current = true;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
port: Number(port),
|
||||
username,
|
||||
});
|
||||
if (res === SSHStatus.CONNECTED) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isClientDisconnected) connect().catch(console.error);
|
||||
}, [
|
||||
isClientDisconnected,
|
||||
isSSHConnected,
|
||||
isSSHConnecting,
|
||||
isClientConnecting,
|
||||
peer.id,
|
||||
port,
|
||||
ssh,
|
||||
username,
|
||||
client.connectTemporary,
|
||||
client.status,
|
||||
]);
|
||||
|
||||
if (client.error) {
|
||||
return <ErrorMessage error={{ message: client.error, code: 0 }} />;
|
||||
}
|
||||
|
||||
if (sshError) {
|
||||
return <ErrorMessage error={{ message: sshError, code: 0 }} />;
|
||||
}
|
||||
|
||||
if (isSSHDisconnected && sshConnectedOnce.current) {
|
||||
return (
|
||||
<DisconnectedMessage
|
||||
username={username}
|
||||
peerIp={peer.ip}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{session && <Terminal session={session} onClose={disconnect} />}
|
||||
{!isSSHConnected && (
|
||||
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageProps = {
|
||||
message?: string;
|
||||
error?: ErrorResponse;
|
||||
};
|
||||
|
||||
const LoadingMessage = ({ message }: MessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<Loader2Icon size={16} className={"animate-spin shrink-0"} />
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ error }: MessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<CircleXIcon size={16} className={"shrink-0 text-red-500"} />
|
||||
{error?.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DisconnectedMessageProps = {
|
||||
username: string;
|
||||
peerIp: string;
|
||||
onReconnect: () => void;
|
||||
};
|
||||
|
||||
const DisconnectedMessage = ({
|
||||
username,
|
||||
peerIp,
|
||||
onReconnect,
|
||||
}: DisconnectedMessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center gap-4"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<InfoIcon size={16} className={"shrink-0 text-nb-gray-200"} />
|
||||
Disconnected from {username}@{peerIp}
|
||||
<button
|
||||
className={
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1 text-netbird hover:underline font-normal"
|
||||
}
|
||||
onClick={onReconnect}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -157,3 +157,20 @@ p {
|
||||
.animate-bg-scroll-faster {
|
||||
animation: bg-scroll 1.8s linear infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal (xterm)
|
||||
*/
|
||||
.xterm {
|
||||
@apply m-0 p-1 box-border h-full w-full;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
@apply m-0 p-0 box-border;
|
||||
}
|
||||
|
||||
|
||||
/* Control Center */
|
||||
.react-flow__node-groupNode .selected{
|
||||
@apply border-netbird;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
22
src/assets/icons/ControlCenterIcon.tsx
Normal file
22
src/assets/icons/ControlCenterIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
BIN
src/assets/ssh/ssh-client.png
Normal file
BIN
src/assets/ssh/ssh-client.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 290 KiB |
@@ -58,6 +58,10 @@ export default function OIDCProvider({ children }: Props) {
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
"page",
|
||||
"page_size",
|
||||
"user",
|
||||
"port",
|
||||
];
|
||||
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const buttonVariants = cva(
|
||||
"relative",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm",
|
||||
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||
"disabled:opacity-20 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -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",
|
||||
|
||||
@@ -34,7 +34,7 @@ const ButtonGroupButton = forwardRef(
|
||||
border={2}
|
||||
rounded={false}
|
||||
className={cn(
|
||||
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[41px]",
|
||||
"first:border-l-0 last:border-r-0 border-t-0 border-b-0 h-[40px]",
|
||||
"!py-2.5 !px-4",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const calloutVariants = cva(
|
||||
|
||||
export const Callout = ({
|
||||
children,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
29
src/components/ListItem.tsx
Normal file
29
src/components/ListItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
src/components/NoPeersGettingStarted.tsx
Normal file
44
src/components/NoPeersGettingStarted.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -42,8 +42,15 @@ import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
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;
|
||||
};
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: Group[];
|
||||
onChange: React.Dispatch<React.SetStateAction<Group[]>>;
|
||||
@@ -60,6 +67,7 @@ interface MultiSelectProps {
|
||||
dataCy?: string;
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
@@ -67,6 +75,7 @@ interface MultiSelectProps {
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -84,6 +93,7 @@ export function PeerGroupSelector({
|
||||
dataCy = "group-selector-dropdown",
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
resource,
|
||||
onResourceChange,
|
||||
placeholder = "Add or select group(s)...",
|
||||
@@ -91,16 +101,36 @@ export function PeerGroupSelector({
|
||||
align = "start",
|
||||
side = "bottom",
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
|
||||
useGroups();
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputRef, { width }] = useElementSize<
|
||||
HTMLButtonElement | HTMLSpanElement
|
||||
>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
const [filteredGroups, search, setSearch] = useSearch(
|
||||
sortedDropdownOptions,
|
||||
groupsSearchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
// Update dropdown options when groups change
|
||||
@@ -141,7 +171,15 @@ export function PeerGroupSelector({
|
||||
const groupResources: GroupResource[] | undefined =
|
||||
(group?.resources as GroupResource[]) || [];
|
||||
|
||||
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
|
||||
if (peer) {
|
||||
const peerInGroup = groupPeers?.find((p) => p?.id === peer?.id);
|
||||
if (!peerInGroup) {
|
||||
groupPeers?.push({
|
||||
id: peer?.id as string,
|
||||
name: peer?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!group && !option) {
|
||||
addDropdownOptions([
|
||||
@@ -189,16 +227,6 @@ export function PeerGroupSelector({
|
||||
return isSearching && groupDoesNotExist && !isAllGroup;
|
||||
}, [search, dropdownOptions]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const folderIcon = useMemo(() => {
|
||||
return <FolderGit2 size={12} className={"shrink-0"} />;
|
||||
}, []);
|
||||
|
||||
const peerIcon = useMemo(() => {
|
||||
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
|
||||
}, []);
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
const [tab, setTab] = useState("groups");
|
||||
@@ -219,12 +247,6 @@ export function PeerGroupSelector({
|
||||
onChange(union);
|
||||
};
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
// Reset the search input when switching tabs
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
@@ -233,10 +255,12 @@ export function PeerGroupSelector({
|
||||
}, 0);
|
||||
}, [tab]);
|
||||
|
||||
const searchPlaceholder =
|
||||
tab === "groups"
|
||||
? 'Search groups or add new group by pressing "Enter"...'
|
||||
: "Search resource...";
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
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) => {
|
||||
onResourceChange?.(
|
||||
@@ -250,6 +274,15 @@ export function PeerGroupSelector({
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
const selectPeer = (peer?: Peer) => {
|
||||
if (!peer?.id) return;
|
||||
onResourceChange?.({
|
||||
id: peer.id,
|
||||
type: "peer",
|
||||
});
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -284,10 +317,11 @@ export function PeerGroupSelector({
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{resource && showResources && (
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -364,16 +398,7 @@ export function PeerGroupSelector({
|
||||
side={side}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<Command className={"w-full flex"} loop shouldFilter={false}>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
@@ -414,13 +439,17 @@ export function PeerGroupSelector({
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
|
||||
{showResources && <TabTriggers searchRef={searchRef} />}
|
||||
<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",
|
||||
sortedDropdownOptions.length == 0 && !search && "py-0",
|
||||
filteredGroups.length == 0 && !search && "py-0",
|
||||
)}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
@@ -433,8 +462,8 @@ export function PeerGroupSelector({
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
<Badge variant={"gray-ghost"} className={"h-7"}>
|
||||
<FolderGit2 size={12} className={"shrink-0"} />
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
@@ -448,7 +477,7 @@ export function PeerGroupSelector({
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
{filteredGroups.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
@@ -490,7 +519,11 @@ export function PeerGroupSelector({
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
<GroupBadge
|
||||
group={option}
|
||||
showNewBadge={true}
|
||||
className={"h-7"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
@@ -509,7 +542,10 @@ export function PeerGroupSelector({
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
<MonitorSmartphoneIcon
|
||||
size={14}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
) : (
|
||||
@@ -535,12 +571,23 @@ export function PeerGroupSelector({
|
||||
<ResourcesList
|
||||
search={search}
|
||||
resources={resources}
|
||||
isLoading={isLoading}
|
||||
isLoading={isResourcesLoading}
|
||||
value={resource}
|
||||
onChange={selectResource}
|
||||
/>
|
||||
</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>
|
||||
@@ -551,9 +598,15 @@ export function PeerGroupSelector({
|
||||
|
||||
const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
}) => {
|
||||
if (!showResources && !showPeers) return null;
|
||||
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
<TabsTrigger
|
||||
@@ -569,19 +622,38 @@ const TabTriggers = ({
|
||||
/>
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
<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}
|
||||
/>
|
||||
Resource
|
||||
</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>
|
||||
)}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -700,7 +772,7 @@ const ResourcesList = ({
|
||||
useHover={true}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap")}
|
||||
className={cn("transition-all group whitespace-nowrap h-7")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
@@ -736,3 +808,107 @@ const ResourcesList = ({
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
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 PeersList = ({
|
||||
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 },
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import FullTooltip from "@components/FullTooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -16,7 +15,7 @@ import { memo, useEffect, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
const MapPinIcon = memo(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
@@ -182,7 +181,6 @@ export function PeerSelector({
|
||||
togglePeer(item);
|
||||
}}
|
||||
renderItem={(option) => {
|
||||
const os = getOperatingSystem(option.os);
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
option.version,
|
||||
option.os,
|
||||
@@ -210,19 +208,10 @@ export function PeerSelector({
|
||||
: "text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<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]",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={option.os} />
|
||||
</div>
|
||||
|
||||
<PeerOperatingSystemIcon
|
||||
os={option.os}
|
||||
className={isSupported ? "" : "opacity-50"}
|
||||
/>
|
||||
<div className={cn(!isSupported && "opacity-50")}>
|
||||
<TextWithTooltip
|
||||
text={option.name}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props<T extends { id?: string }> = {
|
||||
renderHeading?: (item: T) => React.ReactNode;
|
||||
renderBeforeItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
itemClassNameWithItem?: (item: T) => string;
|
||||
itemWrapperClassName?: string;
|
||||
scrollAreaClassName?: string;
|
||||
maxHeight?: number;
|
||||
@@ -21,6 +22,7 @@ type Props<T extends { id?: string }> = {
|
||||
estimatedHeadingHeight?: number;
|
||||
heightAdjustment?: number;
|
||||
groupKey?: (item: T) => string | undefined;
|
||||
itemKey?: (item: T) => string;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
@@ -30,6 +32,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
renderBeforeItem,
|
||||
renderHeading,
|
||||
itemClassName,
|
||||
itemClassNameWithItem,
|
||||
itemWrapperClassName,
|
||||
scrollAreaClassName,
|
||||
maxHeight,
|
||||
@@ -37,6 +40,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
estimatedHeadingHeight = 16,
|
||||
heightAdjustment = 8,
|
||||
groupKey,
|
||||
itemKey,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
|
||||
@@ -159,10 +163,14 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
setSelected(index);
|
||||
}
|
||||
}}
|
||||
id={option.id}
|
||||
id={itemKey ? itemKey(option) : option?.id}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
itemClassName={itemClassName}
|
||||
itemClassName={
|
||||
itemClassNameWithItem
|
||||
? itemClassNameWithItem(option)
|
||||
: itemClassName
|
||||
}
|
||||
className={itemWrapperClassName}
|
||||
isLast={index === items.length - 1}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
{withHeader && <SkeletonTableHeader />}
|
||||
<div className={"mt-6"}>
|
||||
<div className={"mt-6 relative -top-1"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
@@ -68,7 +68,7 @@ export const SkeletonTableHeader = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between",
|
||||
"flex gap-x-4 gap-y-6 p-default flex-wrap w-full justify-between relative -top-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -133,6 +133,7 @@ interface DataTableProps<TData, TValue> {
|
||||
getStartedCard?: React.ReactNode;
|
||||
placeholders?: TData[];
|
||||
renderExpandedRow?: (row: TData) => React.ReactNode;
|
||||
renderRow?: (row: TData, children: React.ReactNode) => React.ReactNode;
|
||||
minimal?: boolean;
|
||||
className?: string;
|
||||
inset?: boolean;
|
||||
@@ -193,6 +194,7 @@ export function DataTable<TData, TValue>({
|
||||
onRowClick,
|
||||
getStartedCard,
|
||||
renderExpandedRow,
|
||||
renderRow,
|
||||
minimal,
|
||||
className,
|
||||
tableClassName,
|
||||
@@ -507,7 +509,7 @@ export function DataTable<TData, TValue>({
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
return (
|
||||
const rowContent = (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
@@ -597,6 +599,8 @@ export function DataTable<TData, TValue>({
|
||||
</>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
return renderRow ? renderRow(row.original, rowContent) : rowContent;
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
|
||||
89
src/components/table/DataTableMultiSelectPopup.tsx
Normal file
89
src/components/table/DataTableMultiSelectPopup.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { MonitorSmartphoneIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Props<T> = {
|
||||
selectedItems?: T[];
|
||||
label?: string;
|
||||
onCanceled?: () => void;
|
||||
rightSide?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function DataTableMultiSelectPopup<T>({
|
||||
onCanceled,
|
||||
label = "Peer(s) selected",
|
||||
selectedItems,
|
||||
rightSide,
|
||||
}: Props<T>) {
|
||||
const count = selectedItems?.length || 0;
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{count > 0 && (
|
||||
<div
|
||||
className={"fixed -bottom-16 z-50 w-full left-0 pointer-events-none"}
|
||||
>
|
||||
<motion.div
|
||||
exit={{
|
||||
y: 100,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: 100 }}
|
||||
exit={{ y: 100 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 270,
|
||||
damping: 25,
|
||||
duration: 0.35,
|
||||
}}
|
||||
className={cn(
|
||||
"max-w-xl mx-auto border relative z-[50] bg-nb-gray-800 border-nb-gray-900 shadow-2xl border-b-0 overflow-hidden pointer-events-auto",
|
||||
"rounded-t-lg",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode={"popLayout"}>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center text-sm px-6 pt-3.5 pb-20 bg-nb-gray-920/90 text-nb-gray-200 justify-between"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<MonitorSmartphoneIcon size={16} className={""} />
|
||||
<span>
|
||||
<span className={"font-medium text-white"}>
|
||||
{count}
|
||||
</span>{" "}
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{rightSide}
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Cancel</span>}
|
||||
>
|
||||
<Button
|
||||
onClick={onCanceled}
|
||||
variant={"default-outline"}
|
||||
size={"xs"}
|
||||
className={"!h-9 !w-9"}
|
||||
>
|
||||
<IconX size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
115
src/components/ui/AddGroupButton.tsx
Normal file
115
src/components/ui/AddGroupButton.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useApiCall } from "@/utils/api";
|
||||
import ModalHeader from "../modal/ModalHeader";
|
||||
import { notify } from "../Notification";
|
||||
import Paragraph from "../Paragraph";
|
||||
import Separator from "../Separator";
|
||||
|
||||
export const AddGroupButton = () => {
|
||||
const create = useApiCall<Group>("/groups", true).post;
|
||||
const { mutate } = useSWRConfig();
|
||||
const [name, setName] = useState<string>("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const createGroup = () => {
|
||||
notify({
|
||||
title: "Create Group",
|
||||
description: `Group '${name}' successfully created`,
|
||||
loadingMessage: "Creating group...",
|
||||
promise: create({ name }).then((g) => {
|
||||
setOpen(false);
|
||||
setName("");
|
||||
mutate("/groups");
|
||||
router.push(`/group?id=${g?.id}`);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
permission?.groups?.create && (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
className={"ml-auto h-[42px]"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Group
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2Icon size={18} />}
|
||||
title="Create Group"
|
||||
description="Create a group to manage and organize access in your network"
|
||||
color="netbird"
|
||||
/>
|
||||
<Separator />
|
||||
<div className={"px-8 flex-col flex gap-6 py-6"}>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>
|
||||
Set an easily identifiable name for your group
|
||||
</HelpText>
|
||||
<Input
|
||||
tabIndex={0}
|
||||
placeholder={"e.g., Developers"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Groups
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
disabled={!name}
|
||||
onClick={createGroup}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Group
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"} />;
|
||||
};
|
||||
|
||||
21
src/components/ui/InstallNetBirdButton.tsx
Normal file
21
src/components/ui/InstallNetBirdButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalTrigger } from "@components/modal/Modal";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
export function InstallNetBirdButton() {
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal open={installModal} onOpenChange={setInstallModal}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"secondary"} size={"sm"}>
|
||||
<DownloadIcon size={16} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { memo } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
|
||||
const MemoizedNetBirdIcon = () => {
|
||||
return <NetBirdIcon size={16} />;
|
||||
return <NetBirdIcon size={14} />;
|
||||
};
|
||||
|
||||
export default memo(MemoizedNetBirdIcon);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Card from "@components/Card";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
@@ -9,15 +10,18 @@ type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function NoResultsCard({
|
||||
icon,
|
||||
title = "Could not find any results",
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"px-8 mt-8"}>
|
||||
<div className={cn("px-8 mt-8", className)}>
|
||||
<Card className={"w-full relative overflow-hidden"}>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -34,31 +34,34 @@ export default function PolicyDirection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
|
||||
const isNetworkResource =
|
||||
!!destinationResource && destinationResource?.type !== "peer";
|
||||
|
||||
const topBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "blueDark";
|
||||
if (isNetworkResource) return "blueDark";
|
||||
if (value === "bi") return "green";
|
||||
if (value === "in") return "blueDark";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
const topArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-sky-500";
|
||||
if (isNetworkResource) return "fill-sky-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
if (value === "in") return "fill-sky-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
const bottomBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "gray";
|
||||
if (isNetworkResource) return "gray";
|
||||
if (value === "bi") return "green";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
const bottomArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-gray-500";
|
||||
if (isNetworkResource) return "fill-gray-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,9 +4,13 @@ import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
peer?: Peer;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
showX?: boolean;
|
||||
children?: React.ReactNode;
|
||||
@@ -15,35 +19,44 @@ type Props = {
|
||||
export default function ResourceBadge({
|
||||
onClick,
|
||||
resource,
|
||||
peer,
|
||||
showX = false,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
if (!resource) return;
|
||||
if (!resource && !peer) return;
|
||||
|
||||
const isPeer = !!peer;
|
||||
const key = resource ? resource.id || resource?.name : peer?.id || peer?.name;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={resource.id || resource?.name}
|
||||
key={key}
|
||||
useHover={true}
|
||||
data-cy={"resource-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap",
|
||||
className,
|
||||
isPeer && "px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{resource.type === "host" && (
|
||||
<WorkflowIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "domain" && (
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "subnet" && (
|
||||
<NetworkIcon size={12} className={"shrink-0"} />
|
||||
{isPeer ? (
|
||||
<>
|
||||
<PeerOperatingSystemIcon os={peer?.os} />
|
||||
<TruncatedText text={peer?.name || ""} maxChars={20} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ResourceIcon type={resource?.type || ""} />
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{showX && (
|
||||
<XIcon
|
||||
@@ -56,3 +69,16 @@ export default function ResourceBadge({
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ResourceIcon = ({ type }: { type: string }) => {
|
||||
switch (type) {
|
||||
case "host":
|
||||
return <WorkflowIcon size={12} className={"shrink-0"} />;
|
||||
case "domain":
|
||||
return <GlobeIcon size={12} className={"shrink-0"} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={12} className={"shrink-0"} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function TextWithTooltip({
|
||||
<FullTooltip
|
||||
disabled={charCount <= maxChars || hideTooltip}
|
||||
interactive={false}
|
||||
className={"truncate w-full min-w-0"}
|
||||
className={"truncate w-auto min-w-0"}
|
||||
skipDelayDuration={350}
|
||||
delayDuration={200}
|
||||
content={
|
||||
|
||||
@@ -4,7 +4,18 @@ import md5 from "crypto-js/md5";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
const initialAnnouncements: Announcement[] = [];
|
||||
const initialAnnouncements: Announcement[] = [
|
||||
{
|
||||
tag: "New",
|
||||
text: "NetBird v0.60.0 - Identity-aware, private SSH over your NetBird network.",
|
||||
link: "https://docs.netbird.io/how-to/ssh",
|
||||
linkText: "Documentation",
|
||||
variant: "default", // "default" or "important"
|
||||
isExternal: true,
|
||||
closeable: true,
|
||||
isCloudOnly: false,
|
||||
},
|
||||
];
|
||||
|
||||
export interface Announcement extends AnnouncementVariant {
|
||||
tag: string;
|
||||
|
||||
335
src/contexts/GroupProvider.tsx
Normal file
335
src/contexts/GroupProvider.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { EditGroupNameModal } from "@/modules/groups/EditGroupNameModal";
|
||||
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
children?: React.ReactNode;
|
||||
isDetailPage?: boolean;
|
||||
};
|
||||
|
||||
const GroupContext = React.createContext(
|
||||
{} as {
|
||||
group: Group;
|
||||
deleteGroup: () => Promise<void>;
|
||||
renameGroup: (name: string) => Promise<void>;
|
||||
isRegularGroup: boolean;
|
||||
isIntegrationGroup: boolean;
|
||||
isJWTGroup: boolean;
|
||||
isAllowedToDelete: boolean;
|
||||
isAllowedToRename: boolean;
|
||||
openGroupRenameModal?: () => void;
|
||||
addPeersToGroup: (peers: Peer[]) => Promise<void>;
|
||||
removePeersFromGroup: (peer: Peer[]) => Promise<void>;
|
||||
addUsersToGroup: (users: User[]) => Promise<void>;
|
||||
removeUsersFromGroup: (users: User[]) => Promise<void>;
|
||||
},
|
||||
);
|
||||
|
||||
export const GroupProvider = ({
|
||||
group,
|
||||
children,
|
||||
isDetailPage = true,
|
||||
}: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const [groupNameModal, setGroupNameModal] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { deleteGroupDropdownOption, updateGroupDropdown } = useGroups();
|
||||
const groupRequest = useApiCall<Group>("/groups/" + group.id);
|
||||
const userRequest = useApiCall<User>("/users");
|
||||
const { confirm } = useDialog();
|
||||
const { isRegularGroup, isIntegrationGroup, isJWTGroup } =
|
||||
useGroupIdentification({
|
||||
id: group?.id,
|
||||
issued: group?.issued,
|
||||
});
|
||||
|
||||
const isAllowedToRename = isRegularGroup && permission?.groups?.update;
|
||||
const isAllowedToDelete = !isIntegrationGroup && permission?.groups?.delete;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isAllowedToDelete) return Promise.reject("Not allowed to delete");
|
||||
|
||||
const promise = groupRequest.del().then(() => {
|
||||
deleteGroupDropdownOption(group.name);
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Delete Group " + group.name,
|
||||
description: "Group successfully deleted",
|
||||
promise,
|
||||
loadingMessage: "Deleting group...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteGroup = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Delete '${group.name}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this group? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
handleDelete().then();
|
||||
};
|
||||
|
||||
const renameGroup = (name: string) => {
|
||||
if (!isAllowedToRename) return Promise.reject("Not allowed to rename");
|
||||
|
||||
const currentPeerIds =
|
||||
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
|
||||
const promise = groupRequest
|
||||
.put({ ...group, peers: currentPeerIds, name })
|
||||
.then(() => {
|
||||
updateGroupDropdown(group.name, { ...group, name });
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `Rename Group ${group.name}`,
|
||||
description: "Group successfully renamed to " + name,
|
||||
promise,
|
||||
loadingMessage: "Renaming group...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const removePeersFromGroup = async (peers: Peer[]) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
const peer = peers.length === 1 ? peers[0] : undefined;
|
||||
|
||||
const choice = await confirm({
|
||||
title: peer
|
||||
? `Remove peer '${peer.name}' from '${group.name}'?`
|
||||
: `Remove peers from '${group.name}'?`,
|
||||
description: peer
|
||||
? `Are you sure you want to remove this peer from the group? You can add it back later if needed.`
|
||||
: `Are you sure you want to remove these peers from the group? You can add them back later if needed.`,
|
||||
confirmText: "Remove",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
|
||||
if (!choice) return Promise.resolve();
|
||||
|
||||
const currentPeerIds =
|
||||
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
|
||||
const newPeerIds = currentPeerIds.filter((pid) => {
|
||||
return !peers.find((peer) => peer.id === pid);
|
||||
});
|
||||
const promise = groupRequest
|
||||
.put({ ...group, peers: newPeerIds })
|
||||
.then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: `Remove Peer from Group`,
|
||||
description: peer
|
||||
? `Peer '${peer.name}' successfully removed from group '${group.name}'`
|
||||
: `Peers successfully removed from group '${group.name}'`,
|
||||
promise,
|
||||
loadingMessage: peer
|
||||
? "Removing peer from group..."
|
||||
: `Removing peers from group...`,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addPeersToGroup = async (peers: Peer[]) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
|
||||
const currentPeerIds =
|
||||
group.peers?.map((p) => (typeof p === "string" ? p : p.id)) || [];
|
||||
const newPeerIds = [...currentPeerIds, ...peers.map((peer) => peer.id)];
|
||||
|
||||
const uniquePeerIds = Array.from(new Set(newPeerIds));
|
||||
|
||||
const promise = groupRequest
|
||||
.put({ ...group, peers: uniquePeerIds })
|
||||
.then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Adding peers to group",
|
||||
description: `Peers were successfully added to ${group.name}.`,
|
||||
promise,
|
||||
loadingMessage: "Adding peers to group...",
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const removeUserFromGroup = async (
|
||||
user: User,
|
||||
returnOnlyPromise?: boolean,
|
||||
) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
if (!permission?.users?.update) return Promise.reject();
|
||||
|
||||
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
|
||||
const newGroupIds = currentGroupIds.filter((gid) => gid !== group.id);
|
||||
const promise = userRequest
|
||||
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
|
||||
.then(() => {
|
||||
if (returnOnlyPromise) return;
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
|
||||
if (!returnOnlyPromise) {
|
||||
notify({
|
||||
title: `Remove User from Group ${group.name}`,
|
||||
description: `User '${user.name}' was successfully removed from group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Removing user from group...",
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const removeUsersFromGroup = async (users: User[]) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
if (!permission?.users?.update) return Promise.reject();
|
||||
let promises = users.map((user) => removeUserFromGroup(user, true));
|
||||
|
||||
const user = users.length === 1 ? users[0] : undefined;
|
||||
|
||||
const choice = await confirm({
|
||||
title: user
|
||||
? `Remove user '${user?.name ?? user?.id}' from '${group.name}'?`
|
||||
: `Remove users from '${group.name}'?`,
|
||||
description: user
|
||||
? `Are you sure you want to remove this user from the group? You can add it back later if needed.`
|
||||
: `Are you sure you want to remove these users from the group? You can add them back later if needed.`,
|
||||
confirmText: "Remove",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
if (!choice) return Promise.resolve();
|
||||
|
||||
const promise = Promise.all(promises).then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
notify({
|
||||
title: `Remove Users from Group ${group.name}`,
|
||||
description: `Users were successfully removed from group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Removing users from group...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addUserToGroup = async (user: User, returnOnlyPromise?: boolean) => {
|
||||
if (!permission?.groups?.update) return Promise.reject();
|
||||
if (!permission?.users?.update) return Promise.reject();
|
||||
const currentGroupIds = user.auto_groups?.map((g) => g) || [];
|
||||
const newGroupIds = Array.from(new Set([...currentGroupIds, group.id]));
|
||||
const promise = userRequest
|
||||
.put({ ...user, auto_groups: newGroupIds }, `/${user.id}`)
|
||||
.then(() => {
|
||||
if (returnOnlyPromise) return;
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
if (!returnOnlyPromise) {
|
||||
notify({
|
||||
title: `Add User to Group ${group.name}`,
|
||||
description: `User '${user.name}' was successfully added to group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Adding user to group...",
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
const addUsersToGroup = async (users: User[]) => {
|
||||
let promises = users.map((user) => addUserToGroup(user, true));
|
||||
const promise = Promise.all(promises).then(() => {
|
||||
if (isDetailPage) mutate(`/groups/${group.id}`);
|
||||
mutate("/groups");
|
||||
mutate("/users?service_user=false");
|
||||
});
|
||||
notify({
|
||||
title: `Add Users to Group ${group.name}`,
|
||||
description: `Users were successfully added to group '${group.name}'.`,
|
||||
promise,
|
||||
loadingMessage: "Adding users to group...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openGroupRenameModal = () => {
|
||||
if (!isAllowedToRename) return;
|
||||
setGroupNameModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupContext.Provider
|
||||
value={{
|
||||
group,
|
||||
deleteGroup,
|
||||
renameGroup,
|
||||
isRegularGroup,
|
||||
isIntegrationGroup,
|
||||
isJWTGroup,
|
||||
isAllowedToDelete,
|
||||
isAllowedToRename,
|
||||
openGroupRenameModal,
|
||||
addPeersToGroup,
|
||||
removePeersFromGroup,
|
||||
addUsersToGroup,
|
||||
removeUsersFromGroup,
|
||||
}}
|
||||
>
|
||||
<EditGroupNameModal
|
||||
initialName={group.name}
|
||||
open={groupNameModal}
|
||||
onOpenChange={setGroupNameModal}
|
||||
onSuccess={(newName) =>
|
||||
renameGroup(newName).then(() => {
|
||||
setGroupNameModal(false);
|
||||
})
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</GroupContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGroupContext = () => {
|
||||
const context = React.useContext(GroupContext);
|
||||
if (!context) {
|
||||
throw new Error("useGroup must be used within a GroupProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -20,6 +20,7 @@ const GroupContext = React.createContext(
|
||||
createOrUpdate: (group: Group) => Promise<Group>;
|
||||
reset: () => void;
|
||||
updateGroupDropdown: (oldGroupName: string, newGroup: Group) => void;
|
||||
deleteGroupDropdownOption: (name: string) => void;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,6 +133,13 @@ export function GroupsProviderContent({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroupDropdownOption = (name: string) => {
|
||||
setDropdownOptions((prev) => {
|
||||
let updated = prev.filter((g) => g.name !== name);
|
||||
return sortBy(updated, "name");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupContext.Provider
|
||||
value={{
|
||||
@@ -144,6 +152,7 @@ export function GroupsProviderContent({
|
||||
createOrUpdate,
|
||||
reset,
|
||||
updateGroupDropdown,
|
||||
deleteGroupDropdownOption,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import SkeletonPeerDetail from "@components/skeletons/SkeletonPeerDetail";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { PeerSSHInstructions } from "@/modules/peer/PeerSSHInstructions";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
peer: Peer;
|
||||
isPeerDetailPage?: boolean;
|
||||
};
|
||||
|
||||
const PeerContext = React.createContext(
|
||||
@@ -28,18 +31,25 @@ const PeerContext = React.createContext(
|
||||
approval_required?: boolean;
|
||||
ip?: string;
|
||||
}) => Promise<Peer>;
|
||||
openSSHDialog: () => Promise<boolean>;
|
||||
toggleSSH: (newState: boolean) => Promise<void>;
|
||||
setSSHInstructionsModal: (open: boolean) => void;
|
||||
deletePeer: () => void;
|
||||
isLoading: boolean;
|
||||
},
|
||||
);
|
||||
|
||||
export default function PeerProvider({ children, peer }: Props) {
|
||||
export default function PeerProvider({
|
||||
children,
|
||||
peer,
|
||||
isPeerDetailPage = false,
|
||||
}: Props) {
|
||||
const user = usePeerUser(peer);
|
||||
const { peerGroups, isLoading } = usePeerGroups(peer);
|
||||
const peerRequest = useApiCall<Peer>("/peers", true);
|
||||
const { confirm } = useDialog();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const [sshInstructionsModal, setSSHInstructionsModal] = useState(false);
|
||||
|
||||
const deletePeer = async () => {
|
||||
const choice = await confirm({
|
||||
@@ -94,14 +104,20 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const openSSHDialog = async (): Promise<boolean> => {
|
||||
return await confirm({
|
||||
title: `Enable SSH Server for ${peer.name}?`,
|
||||
description:
|
||||
"Experimental feature. Enabling this option allows remote SSH access to this machine from other connected network participants.",
|
||||
confirmText: "Enable",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
const toggleSSH = async (enable: boolean) => {
|
||||
if (!permission.peers.update) return;
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: enable
|
||||
? "SSH Access successfully enabled"
|
||||
: "SSH Access successfully disabled",
|
||||
promise: update({ ssh: enable }).then(() => {
|
||||
isPeerDetailPage ? mutate(`/peers/${peer.id}`) : mutate("/peers");
|
||||
setSSHInstructionsModal(false);
|
||||
}),
|
||||
loadingMessage: enable
|
||||
? "Enabling SSH Access..."
|
||||
: "Disabling SSH Access...",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -112,16 +128,26 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
peerGroups,
|
||||
user,
|
||||
update,
|
||||
openSSHDialog,
|
||||
toggleSSH,
|
||||
setSSHInstructionsModal,
|
||||
deletePeer,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{sshInstructionsModal && (
|
||||
<PeerSSHInstructions
|
||||
open={sshInstructionsModal}
|
||||
onOpenChange={setSSHInstructionsModal}
|
||||
peer={peer}
|
||||
onSuccess={() => toggleSSH(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PeerContext.Provider>
|
||||
) : (
|
||||
) : isPeerDetailPage ? (
|
||||
<SkeletonPeerDetail />
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,6 @@ const UserProfileProvider = ({ children }: Props) => {
|
||||
}
|
||||
}, [user, error, users, isLoading, isAllUsersLoading]);
|
||||
|
||||
|
||||
const data = useMemo(() => {
|
||||
return {
|
||||
loggedInUser,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -26,3 +26,14 @@ export enum GroupIssued {
|
||||
INTEGRATION = "integration",
|
||||
JWT = "jwt",
|
||||
}
|
||||
|
||||
export const GROUP_TOOLTIP_TEXT = {
|
||||
RENAME: {
|
||||
JWT: "This group is issued by JWT and cannot be renamed.",
|
||||
INTEGRATION: "This group is issued by an IdP and cannot be renamed.",
|
||||
},
|
||||
DELETE: {
|
||||
INTEGRATION: "This group is issued by an IdP and cannot be deleted.",
|
||||
},
|
||||
IN_USE: "Remove dependencies to this group to delete it.",
|
||||
};
|
||||
|
||||
@@ -104,50 +104,4 @@ export const NameserverPresets: Record<string, NameserverGroup> = {
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0: {
|
||||
name: "DNS0.EU",
|
||||
description: "DNS0.EU DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0Zero: {
|
||||
name: "DNS0.EU Zero",
|
||||
description: "DNS0.EU Zero DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,3 +28,7 @@ export interface NetworkResource {
|
||||
type?: "domain" | "host" | "subnet";
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkResourceWithNetwork extends NetworkResource {
|
||||
network: Network;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Peer {
|
||||
name: string;
|
||||
ip: string;
|
||||
connected: boolean;
|
||||
created_at?: Date;
|
||||
last_seen: Date;
|
||||
os: string;
|
||||
version: string;
|
||||
@@ -15,6 +16,7 @@ export interface Peer {
|
||||
user_id?: string;
|
||||
user?: User;
|
||||
ui_version?: string;
|
||||
kernel_version?: string;
|
||||
dns_label: string;
|
||||
extra_dns_labels?: string[];
|
||||
last_login: Date;
|
||||
@@ -26,4 +28,5 @@ export interface Peer {
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
ephemeral: boolean;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface PortRange {
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
type?: "domain" | "host" | "subnet" | "peer";
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function NavbarWithDropdown() {
|
||||
<AnnouncementBanner />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray/50 backdrop-blur-lg sm:px-6",
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
|
||||
"flex justify-between items-center transition-all",
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
@@ -93,6 +113,12 @@ export default function Navigation({
|
||||
exactPathMatch={true}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Groups"
|
||||
isChild
|
||||
href={"/groups"}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Posture Checks"
|
||||
isChild
|
||||
|
||||
@@ -45,7 +45,7 @@ import React, { useMemo, useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy, Protocol } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { useAccessControl } from "@/modules/access-control/useAccessControl";
|
||||
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
|
||||
@@ -116,6 +116,9 @@ type ModalProps = {
|
||||
postureCheckTemplates?: PostureCheck[];
|
||||
useSave?: boolean;
|
||||
allowEditPeers?: boolean;
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -128,6 +131,9 @@ export function AccessControlModalContent({
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -156,6 +162,8 @@ export function AccessControlModalContent({
|
||||
submit,
|
||||
isPostureChecksLoading,
|
||||
getPolicyData,
|
||||
sourceResource,
|
||||
setSourceResource,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
@@ -168,6 +176,9 @@ export function AccessControlModalContent({
|
||||
initialDestinationGroups,
|
||||
initialName,
|
||||
initialDescription,
|
||||
initialPorts,
|
||||
initialProtocol,
|
||||
initialDestinationResource,
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState(() => {
|
||||
@@ -176,15 +187,17 @@ export function AccessControlModalContent({
|
||||
return "policy";
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, destinationResource]);
|
||||
const canContinueToPostureChecks = useMemo(() => {
|
||||
const hasSource = sourceGroups.length > 0 || !!sourceResource;
|
||||
const hasDestination =
|
||||
destinationGroups.length > 0 || !!destinationResource;
|
||||
return hasSource && hasDestination;
|
||||
}, [sourceGroups, destinationGroups, destinationResource, sourceResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
if (continuePostureChecksDisabled) return true;
|
||||
}, [name, continuePostureChecksDisabled]);
|
||||
if (!canContinueToPostureChecks) return true;
|
||||
}, [name, canContinueToPostureChecks]);
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
@@ -220,11 +233,8 @@ export function AccessControlModalContent({
|
||||
<ArrowRightLeft size={16} />
|
||||
Policy
|
||||
</TabsTrigger>
|
||||
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
|
||||
<TabsTrigger
|
||||
value={"general"}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
<PostureCheckTabTrigger disabled={!canContinueToPostureChecks} />
|
||||
<TabsTrigger value={"general"} disabled={!canContinueToPostureChecks}>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
@@ -283,14 +293,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select source(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={false}
|
||||
showPeers={true}
|
||||
showResourceCounter={false}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
popoverWidth={500}
|
||||
showRoutes={false}
|
||||
onChange={setSourceGroups}
|
||||
values={sourceGroups}
|
||||
onChange={setSourceGroups}
|
||||
resource={sourceResource}
|
||||
onResourceChange={setSourceResource}
|
||||
saveGroupAssignments={useSave}
|
||||
showResourceCounter={false}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
@@ -310,17 +325,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"destination-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select destination(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={true}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
popoverWidth={500}
|
||||
onChange={setDestinationGroups}
|
||||
values={destinationGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
onChange={setDestinationGroups}
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
showResources={true}
|
||||
placeholder={"Select destination(s)..."}
|
||||
saveGroupAssignments={useSave}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
@@ -453,35 +470,36 @@ export function AccessControlModalContent({
|
||||
{!policy ? (
|
||||
<>
|
||||
{tab == "policy" && (
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={!canContinueToPostureChecks}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button variant={"secondary"} onClick={() => setTab("policy")}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "policy" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("policy")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={!canContinueToPostureChecks}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab == "general" && (
|
||||
|
||||
@@ -37,6 +37,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
if (rule.sourceResource) {
|
||||
rule.sources = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -20,30 +18,13 @@ export default function AccessControlDestinationsCell({
|
||||
|
||||
if (firstRule?.destinationResource) {
|
||||
return (
|
||||
<AccessControlDestinationResourceCell
|
||||
resource={firstRule.destinationResource}
|
||||
/>
|
||||
<AccessControlResourceCell resource={firstRule.destinationResource} />
|
||||
);
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
) : null;
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
|
||||
const AccessControlDestinationResourceCell = ({
|
||||
resource,
|
||||
}: {
|
||||
resource: PolicyRuleResource;
|
||||
}) => {
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
if (isLoading) return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge resource={resources?.find((r) => r.id === resource.id)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ export default function AccessControlDirectionCell({
|
||||
}, [policy]);
|
||||
|
||||
const bidirectional = firstRule ? firstRule.bidirectional : false;
|
||||
const isSingleResource = !!firstRule?.destinationResource;
|
||||
const isSingleResource =
|
||||
!!firstRule?.destinationResource &&
|
||||
firstRule?.destinationResource?.type !== "peer";
|
||||
|
||||
return (
|
||||
<div className={"flex h-full"}>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
resource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export const AccessControlResourceCell = ({ resource }: Props) => {
|
||||
const { data: resources, isLoading: isLoadingResources } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
const { data: peers, isLoading: isLoadingPeers } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const isPeer = resource?.type === "peer";
|
||||
const peer = peers?.find((p) => p.id === resource?.id);
|
||||
|
||||
if ((isPeer && isLoadingPeers) || (!isPeer && isLoadingResources))
|
||||
return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge
|
||||
resource={resources?.find((r) => r.id === resource?.id)}
|
||||
peer={peer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import React, { useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -12,7 +14,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
if (firstRule?.sourceResource) {
|
||||
return <AccessControlResourceCell resource={firstRule.sourceResource} />;
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.sources as Group[]} />
|
||||
) : null;
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import Card from "@components/Card";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -9,11 +11,12 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import NoResults from "@/components/ui/NoResults";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import type { Policy } from "@/interfaces/Policy";
|
||||
@@ -34,6 +37,7 @@ type Props = {
|
||||
policies?: Policy[];
|
||||
isLoading: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
isGroupPage?: boolean;
|
||||
};
|
||||
|
||||
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
|
||||
@@ -178,12 +182,13 @@ export default function AccessControlTable({
|
||||
policies,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
isGroupPage,
|
||||
}: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const path = usePathname();
|
||||
const { permission } = usePermissions();
|
||||
const params = useSearchParams();
|
||||
const idParam = params.get("id") ?? undefined;
|
||||
const idParam = !isGroupPage ? params.get("id") : undefined;
|
||||
|
||||
// Default sorting state of the table
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
@@ -194,12 +199,48 @@ export default function AccessControlTable({
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
!isGroupPage,
|
||||
);
|
||||
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<Policy>();
|
||||
const [currentCellClicked, setCurrentCellClicked] = useState("");
|
||||
|
||||
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 tempPolicies = useMemo(
|
||||
() => withTemporaryPolicies(true),
|
||||
[withTemporaryPolicies],
|
||||
);
|
||||
const regularPolicies = useMemo(
|
||||
() => withTemporaryPolicies(false),
|
||||
[withTemporaryPolicies],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTemporaryPolicies && tempPolicies?.length === 0) {
|
||||
setShowTemporaryPolicies(false);
|
||||
}
|
||||
}, [showTemporaryPolicies, tempPolicies]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editModal && currentRow && (
|
||||
@@ -213,7 +254,13 @@ export default function AccessControlTable({
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
keepStateInLocalStorage={!idParam}
|
||||
wrapperComponent={isGroupPage ? Card : undefined}
|
||||
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
|
||||
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
|
||||
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
|
||||
inset={!isGroupPage}
|
||||
minimal={isGroupPage}
|
||||
keepStateInLocalStorage={!isGroupPage || !idParam}
|
||||
initialSearch={idParam ? "" : undefined}
|
||||
initialFilters={
|
||||
idParam
|
||||
@@ -232,8 +279,9 @@ export default function AccessControlTable({
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
temporary: false,
|
||||
}}
|
||||
data={policies}
|
||||
data={showTemporaryPolicies ? tempPolicies : regularPolicies}
|
||||
onRowClick={(row, cell) => {
|
||||
setCurrentRow(row.original);
|
||||
setEditModal(true);
|
||||
@@ -241,25 +289,22 @@ export default function AccessControlTable({
|
||||
}}
|
||||
searchPlaceholder={"Search by name and description..."}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<AccessControlIcon className={"fill-nb-gray-200"} size={20} />
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Policy"}
|
||||
description={
|
||||
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
|
||||
}
|
||||
button={
|
||||
isGroupPage ? (
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group is not used within any policies yet"}
|
||||
description={
|
||||
"Assign this group as either a source or destination inside a policy to see them listed here."
|
||||
}
|
||||
icon={
|
||||
<AccessControlIcon size={20} className={"fill-nb-gray-300"} />
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-4 items-center justify-center"}>
|
||||
<AccessControlModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
disabled={!permission.policies.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
@@ -267,25 +312,59 @@ export default function AccessControlTable({
|
||||
</Button>
|
||||
</AccessControlModal>
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</NoResults>
|
||||
) : (
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<AccessControlIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Policy"}
|
||||
description={
|
||||
"It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports."
|
||||
}
|
||||
button={
|
||||
<div className={"flex gap-4 items-center justify-center"}>
|
||||
<AccessControlModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!permission.policies.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Policy
|
||||
</Button>
|
||||
</AccessControlModal>
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-network-access"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
{policies && policies?.length > 0 && (
|
||||
<div className={"flex ml-auto gap-4"}>
|
||||
<div className={"flex items-center ml-auto"}>
|
||||
<AccessControlModal>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
@@ -301,65 +380,90 @@ export default function AccessControlTable({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={policies?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
{(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}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
/>
|
||||
|
||||
{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={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
mutate("/policies").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={policies?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/policies").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy, PortRange, Protocol } from "@/interfaces/Policy";
|
||||
import {
|
||||
Policy,
|
||||
PolicyRuleResource,
|
||||
PortRange,
|
||||
Protocol,
|
||||
} from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
|
||||
@@ -18,6 +23,9 @@ type Props = {
|
||||
initialDestinationGroups?: Group[] | string[];
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialProtocol?: Protocol;
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
// TODO add reducer
|
||||
@@ -29,6 +37,9 @@ export const useAccessControl = ({
|
||||
initialName,
|
||||
initialDescription,
|
||||
onSuccess,
|
||||
initialProtocol,
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
}: Props = {}) => {
|
||||
const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
@@ -75,6 +86,7 @@ export const useAccessControl = ({
|
||||
const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true);
|
||||
|
||||
const [ports, setPorts] = useState<number[]>(() => {
|
||||
if (initialPorts) return initialPorts;
|
||||
if (!firstRule) return [];
|
||||
if (firstRule.ports == undefined) return [];
|
||||
if (firstRule.ports.length > 0) {
|
||||
@@ -93,7 +105,7 @@ export const useAccessControl = ({
|
||||
});
|
||||
|
||||
const [protocol, setProtocol] = useState<Protocol>(
|
||||
firstRule ? firstRule.protocol : "all",
|
||||
firstRule ? firstRule.protocol : initialProtocol ?? "all",
|
||||
);
|
||||
const [direction, setDirection] = useState<Direction>(() => {
|
||||
if (!firstRule) return "bi";
|
||||
@@ -126,8 +138,12 @@ export const useAccessControl = ({
|
||||
: initialDestinationGroups ?? [],
|
||||
});
|
||||
|
||||
const [sourceResource, setSourceResource] = useState(
|
||||
firstRule?.sourceResource,
|
||||
);
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
firstRule?.destinationResource ?? initialDestinationResource,
|
||||
);
|
||||
|
||||
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});
|
||||
@@ -163,8 +179,9 @@ export const useAccessControl = ({
|
||||
bidirectional: direction == "bi",
|
||||
description,
|
||||
name,
|
||||
sources: sources,
|
||||
sources: sourceResource ? undefined : sources,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
sourceResource: sourceResource || undefined,
|
||||
destinationResource: destinationResource || undefined,
|
||||
action: "accept",
|
||||
protocol,
|
||||
@@ -241,8 +258,9 @@ export const useAccessControl = ({
|
||||
action: "accept",
|
||||
protocol,
|
||||
enabled,
|
||||
sources,
|
||||
sources: sourceResource ? undefined : sources,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
sourceResource: sourceResource || undefined,
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
@@ -254,9 +272,9 @@ export const useAccessControl = ({
|
||||
updatePolicy(
|
||||
policy,
|
||||
policyObj,
|
||||
() => {
|
||||
(p) => {
|
||||
mutate("/policies");
|
||||
onSuccess && onSuccess(policy);
|
||||
onSuccess && onSuccess(p);
|
||||
},
|
||||
"The policy was successfully saved",
|
||||
);
|
||||
@@ -276,7 +294,10 @@ export const useAccessControl = ({
|
||||
const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp";
|
||||
const portDisabled = !hasPortSupport(protocol);
|
||||
|
||||
const isDestinationPeer = destinationResource?.type === "peer";
|
||||
|
||||
const destinationHasResources = useMemo(() => {
|
||||
if (isDestinationPeer) return false;
|
||||
if (destinationResource) return true;
|
||||
|
||||
return destinationGroups.some((group) => {
|
||||
@@ -288,9 +309,10 @@ export const useAccessControl = ({
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [destinationGroups, destinationResource]);
|
||||
}, [destinationGroups, destinationResource, isDestinationPeer]);
|
||||
|
||||
const destinationOnlyResources = useMemo(() => {
|
||||
if (isDestinationPeer) return false;
|
||||
if (destinationResource) return true;
|
||||
|
||||
return (
|
||||
@@ -312,13 +334,13 @@ export const useAccessControl = ({
|
||||
return hasResources && !hasPeers;
|
||||
})
|
||||
);
|
||||
}, [destinationGroups, destinationResource]);
|
||||
}, [destinationGroups, destinationResource, isDestinationPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (destinationOnlyResources && direction !== "in") {
|
||||
if (destinationOnlyResources && direction !== "in" && !isDestinationPeer) {
|
||||
setDirection("in");
|
||||
}
|
||||
}, [destinationOnlyResources, direction, setDirection]);
|
||||
}, [destinationOnlyResources, direction, setDirection, isDestinationPeer]);
|
||||
|
||||
return {
|
||||
protocol,
|
||||
@@ -345,6 +367,8 @@ export const useAccessControl = ({
|
||||
getPolicyData,
|
||||
portDisabled,
|
||||
isPostureChecksLoading,
|
||||
sourceResource,
|
||||
setSourceResource,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
destinationHasResources,
|
||||
|
||||
@@ -365,6 +365,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "peer.user.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
|
||||
with the NetBird IP <Value>{m.ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
@@ -383,6 +391,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "group.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Group <Value>{event.meta.old_name}</Value> was renamed to{" "}
|
||||
<Value>{event.meta.new_name}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Account
|
||||
*/
|
||||
@@ -669,6 +685,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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -37,12 +37,13 @@ export default function ActiveInactiveRow({
|
||||
<div className={"flex gap-2.5 items-start"}>
|
||||
<CircleIcon
|
||||
active={active}
|
||||
size={8}
|
||||
inactiveDot={inactiveDot}
|
||||
className={"mt-[0.34rem] shrink-0"}
|
||||
className={"mt-[0.45rem] shrink-0"}
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0"}>
|
||||
<div
|
||||
className={"font-medium flex gap-2 items-center justify-center"}
|
||||
className={"font-medium flex gap-2 items-center justify-start"}
|
||||
>
|
||||
<TextWithTooltip text={text as string} maxChars={25} />
|
||||
{additionalInfo}
|
||||
|
||||
48
src/modules/control-center/FlowSelector.tsx
Normal file
48
src/modules/control-center/FlowSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/modules/control-center/NetworkRoutingPeerCount.tsx
Normal file
48
src/modules/control-center/NetworkRoutingPeerCount.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
125
src/modules/control-center/edges/AnimatedLine.tsx
Normal file
125
src/modules/control-center/edges/AnimatedLine.tsx
Normal 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;
|
||||
70
src/modules/control-center/edges/BidirectionalEdges.tsx
Normal file
70
src/modules/control-center/edges/BidirectionalEdges.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
src/modules/control-center/edges/DirectionIn.tsx
Normal file
92
src/modules/control-center/edges/DirectionIn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/modules/control-center/edges/FloatingEdge.tsx
Normal file
53
src/modules/control-center/edges/FloatingEdge.tsx
Normal 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;
|
||||
45
src/modules/control-center/edges/SimpleConnection.tsx
Normal file
45
src/modules/control-center/edges/SimpleConnection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/modules/control-center/nodes/DeviceCard.tsx
Normal file
111
src/modules/control-center/nodes/DeviceCard.tsx
Normal 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} />;
|
||||
}
|
||||
};
|
||||
80
src/modules/control-center/nodes/GroupNode.tsx
Normal file
80
src/modules/control-center/nodes/GroupNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
src/modules/control-center/nodes/NetworkNode.tsx
Normal file
102
src/modules/control-center/nodes/NetworkNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
src/modules/control-center/nodes/PeerNode.tsx
Normal file
44
src/modules/control-center/nodes/PeerNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
66
src/modules/control-center/nodes/PolicyNode.tsx
Normal file
66
src/modules/control-center/nodes/PolicyNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
src/modules/control-center/nodes/ResourceNode.tsx
Normal file
41
src/modules/control-center/nodes/ResourceNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
src/modules/control-center/nodes/SelectGroupNode.tsx
Normal file
135
src/modules/control-center/nodes/SelectGroupNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
src/modules/control-center/nodes/SelectPeerNode.tsx
Normal file
102
src/modules/control-center/nodes/SelectPeerNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
src/modules/control-center/utils/edge-helper.ts
Normal file
90
src/modules/control-center/utils/edge-helper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
src/modules/control-center/utils/edges.ts
Normal file
13
src/modules/control-center/utils/edges.ts
Normal 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,
|
||||
};
|
||||
145
src/modules/control-center/utils/helpers.ts
Normal file
145
src/modules/control-center/utils/helpers.ts
Normal 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];
|
||||
}
|
||||
245
src/modules/control-center/utils/layouts.ts
Normal file
245
src/modules/control-center/utils/layouts.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
20
src/modules/control-center/utils/nodes.ts
Normal file
20
src/modules/control-center/utils/nodes.ts
Normal 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,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user