Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
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
@@ -33,6 +33,7 @@ import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
@@ -65,6 +66,8 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -347,6 +350,16 @@ const PeerGeneralInformation = () => {
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
{/* Remote Access Buttons */}
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permission.groups.read && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
@@ -569,6 +582,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{peer.created_at && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<CalendarDays size={16} />
|
||||
Registered on
|
||||
</>
|
||||
}
|
||||
value={
|
||||
dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") +
|
||||
" (" +
|
||||
dayjs().to(peer.created_at) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
212
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
212
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"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 { 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";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
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"}>
|
||||
{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`;
|
||||
}, []);
|
||||
|
||||
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,
|
||||
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" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
233
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
233
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
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 rules = [`tcp/${port}`];
|
||||
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 rules = [`tcp/${port}`];
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -189,16 +219,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 +239,6 @@ export function PeerGroupSelector({
|
||||
onChange(union);
|
||||
};
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
// Reset the search input when switching tabs
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
@@ -233,10 +247,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 +266,15 @@ export function PeerGroupSelector({
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
const selectPeer = (peer?: Peer) => {
|
||||
if (!peer?.id) return;
|
||||
onResourceChange?.({
|
||||
id: peer.id,
|
||||
type: "peer",
|
||||
});
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -288,6 +313,7 @@ export function PeerGroupSelector({
|
||||
<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 +390,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 +431,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 +454,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 +469,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 +511,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 +534,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 +563,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 +590,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 +614,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 +764,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 +800,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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
className={"h-[44px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled == true ? true : disabled}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
className={"h-[44px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -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"} />;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -97,11 +97,22 @@ 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.",
|
||||
description: (
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<div>
|
||||
Enabling this option allows remote SSH access to this machine from
|
||||
other connected network participants.
|
||||
</div>
|
||||
<div>
|
||||
Make sure SSH is allowed in the NetBird Client under{" "}
|
||||
<span className={"text-white"}>Settings → Allow SSH</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: "Enable",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -156,6 +156,8 @@ export function AccessControlModalContent({
|
||||
submit,
|
||||
isPostureChecksLoading,
|
||||
getPolicyData,
|
||||
sourceResource,
|
||||
setSourceResource,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
@@ -176,15 +178,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 +224,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 +284,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select source(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={false}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
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 +316,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 +461,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" && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,6 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -9,9 +10,9 @@ 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 { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -200,6 +201,41 @@ export default function AccessControlTable({
|
||||
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 && (
|
||||
@@ -232,8 +268,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);
|
||||
@@ -301,65 +338,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -126,6 +126,10 @@ export const useAccessControl = ({
|
||||
: initialDestinationGroups ?? [],
|
||||
});
|
||||
|
||||
const [sourceResource, setSourceResource] = useState(
|
||||
firstRule?.sourceResource,
|
||||
);
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
);
|
||||
@@ -163,8 +167,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 +246,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 +260,9 @@ export const useAccessControl = ({
|
||||
updatePolicy(
|
||||
policy,
|
||||
policyObj,
|
||||
() => {
|
||||
(p) => {
|
||||
mutate("/policies");
|
||||
onSuccess && onSuccess(policy);
|
||||
onSuccess && onSuccess(p);
|
||||
},
|
||||
"The policy was successfully saved",
|
||||
);
|
||||
@@ -345,6 +351,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
|
||||
*/
|
||||
@@ -669,6 +677,20 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Limit traffic event group <Value>{m.group_name}</Value> removed
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Limit traffic event group <Value>{m.group_name}</Value> added
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2.5 items-center"}>
|
||||
<span className={"mb-[1px]"}>{event.activity}</span>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -20,9 +20,9 @@ export const NetworkInformationSquare = ({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center max-w-[300px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
|
||||
"flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
|
||||
onClick
|
||||
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-5 relative"
|
||||
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-14 relative"
|
||||
: "cursor-default",
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -53,7 +53,7 @@ export const NetworkInformationSquare = ({
|
||||
<div className={"mt-[0px] flex items-center flex-wrap"}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium",
|
||||
"font-medium text-left whitespace-nowrap",
|
||||
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
|
||||
)}
|
||||
>
|
||||
|
||||
21
src/modules/peer/EphemeralPeerIndicator.tsx
Normal file
21
src/modules/peer/EphemeralPeerIndicator.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {PowerOffIcon} from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const EphemeralPeerIndicator = ({ peer }: Props) => {
|
||||
if (!peer.ephemeral) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = "This peer is an ephemeral peer. If it is disconnected for more than 10 minutes it will be removed.";
|
||||
|
||||
return (
|
||||
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
|
||||
<PowerOffIcon size={12} className={"shrink-0 text-yellow-400"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
23
src/modules/peer/ExpirationDisabledIndicator.tsx
Normal file
23
src/modules/peer/ExpirationDisabledIndicator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { TimerResetIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const ExpirationDisabledIndicator = ({ peer }: Props) => {
|
||||
if (peer.login_expiration_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = "Expiration is disabled for this peer.";
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}
|
||||
>
|
||||
<TimerResetIcon size={14} className={"shrink-0 text-nb-gray-300"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
27
src/modules/peer/LoginRequiredIndicator.tsx
Normal file
27
src/modules/peer/LoginRequiredIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const LoginRequiredIndicator = ({ peer }: Props) => {
|
||||
if (!peer.login_expired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{" "}
|
||||
This peer is offline and needs to be <br />
|
||||
re-authenticated because its login has expired.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={14} className={"shrink-0 text-red-500"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export default function PeerActionCell() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<div className={"flex justify-end pr-4 gap-3"}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex gap-4 items-center min-w-[320px] max-w-[320px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
|
||||
"flex gap-2.5 items-center min-w-[300px] max-w-[300px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -32,13 +32,13 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full h-8 w-8 shrink-0 bg-nb-gray-920/80 transition-all",
|
||||
"flex items-center justify-center rounded-full h-3 w-3 shrink-0 relative -top-[0.5rem]",
|
||||
)}
|
||||
>
|
||||
{isEmpty(peer.country_code) ? (
|
||||
<GlobeIcon size={16} className={"text-nb-gray-300"} />
|
||||
) : (
|
||||
<RoundedFlag country={peer.country_code} size={20} />
|
||||
<RoundedFlag country={peer.country_code} size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ListItem } from "@components/ListItem";
|
||||
import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -104,30 +104,3 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
80
src/modules/peers/PeerConnectButton.tsx
Normal file
80
src/modules/peers/PeerConnectButton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export const PeerConnectButton = () => {
|
||||
const { peer } = usePeer();
|
||||
const isConnected = peer.connected;
|
||||
const os = getOperatingSystem(peer?.os);
|
||||
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
|
||||
|
||||
if (isMobile) return;
|
||||
|
||||
return isConnected ? (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={"group"}>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto"
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
sideOffset={8}
|
||||
>
|
||||
<SSHButton peer={peer} isDropdown={true} />
|
||||
<RDPButton peer={peer} isDropdown={true} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-[200px] text-xs"}>
|
||||
Connecting via SSH or RDP is only available when the peer is online.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConnectButton disabled={true} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex gap-2 items-center text-sm text-nb-gray-300 hover:text-white disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-nb-gray-800/60 rounded-md py-2 px-3 disabled:text-nb-gray-700",
|
||||
// group data state open
|
||||
"group-data-[state=open]:bg-nb-gray-800/30",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
<IconChevronDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
|
||||
import { ExitNodePeerIndicator } from "@/modules/exit-node/ExitNodePeerIndicator";
|
||||
import { EphemeralPeerIndicator } from "@/modules/peer/EphemeralPeerIndicator";
|
||||
import { ExpirationDisabledIndicator } from "@/modules/peer/ExpirationDisabledIndicator";
|
||||
import { LoginRequiredIndicator } from "@/modules/peer/LoginRequiredIndicator";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
@@ -27,7 +30,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
||||
"flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
||||
linkToPeer &&
|
||||
"hover:text-neutral-100 hover:bg-nb-gray-800/60 cursor-pointer",
|
||||
)}
|
||||
@@ -39,7 +42,14 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
||||
active={peer.connected}
|
||||
text={peer.name}
|
||||
additionalInfo={
|
||||
isOwnerOrAdmin && <ExitNodePeerIndicator peer={peer} />
|
||||
isOwnerOrAdmin && (
|
||||
<>
|
||||
<ExitNodePeerIndicator peer={peer} />
|
||||
<EphemeralPeerIndicator peer={peer} />
|
||||
<ExpirationDisabledIndicator peer={peer} />
|
||||
<LoginRequiredIndicator peer={peer} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={"text-nb-gray-400 font-light truncate"}>
|
||||
|
||||
28
src/modules/peers/PeerOperatingSystemIcon.tsx
Normal file
28
src/modules/peers/PeerOperatingSystemIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
os: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const PeerOperatingSystemIcon = ({ os, className }: Props) => {
|
||||
const operatingSystem = getOperatingSystem(os);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
operatingSystem === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
operatingSystem === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
operatingSystem === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<OSLogo os={os} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,8 +2,7 @@ import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { notify } from "@components/Notification";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import { HelpCircle, TimerResetIcon } from "lucide-react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
@@ -49,46 +48,39 @@ export default function PeerStatusCell({ peer }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
return needsApproval ? (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>
|
||||
The peer needs to be approved by an administrator before it can
|
||||
connect to other peers.
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
>
|
||||
<Badge variant={"netbird"} className={"px-3 font-medium"}>
|
||||
<HelpCircle size={12} />
|
||||
Approval required
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"h-[32px]"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!canApprove) return;
|
||||
approvePeer();
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
{!peer.login_expiration_enabled && (
|
||||
<Badge variant={"gray"} className={"px-2"}>
|
||||
<TimerResetIcon size={13} className={"relative -top-[1px]"} />
|
||||
Expiration disabled
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
needsApproval && (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>
|
||||
The peer needs to be approved by an administrator before it can
|
||||
connect to other peers.
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
>
|
||||
<Badge variant={"netbird"} className={"px-3 font-medium"}>
|
||||
<HelpCircle size={12} />
|
||||
Approval required
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
{ canApprove && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"h-[32px]"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!canApprove) return;
|
||||
approvePeer();
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -8,17 +9,20 @@ import {
|
||||
import MemoizedNetBirdIcon from "@components/ui/MemoizedNetBirdIcon";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { parseVersionString } from "@utils/version";
|
||||
import { trim } from "lodash";
|
||||
import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
type Props = {
|
||||
version: string;
|
||||
os: string;
|
||||
serial?: string;
|
||||
};
|
||||
export default function PeerVersionCell({ version, os }: Props) {
|
||||
export default function PeerVersionCell({ version, os, serial }: Props) {
|
||||
const { latestVersion, latestUrl } = useApplicationContext();
|
||||
|
||||
const updateAvailable = useMemo(() => {
|
||||
@@ -35,56 +39,85 @@ export default function PeerVersionCell({ version, os }: Props) {
|
||||
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;
|
||||
}, []);
|
||||
|
||||
return updateAvailable ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger>
|
||||
<div className="flex 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 items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
<div className={"relative"}>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20"></span>
|
||||
{updateIcon}
|
||||
const isWasmClient = trim(os) === "js";
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col gap-1"}>
|
||||
{updateAvailable ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger>
|
||||
<div className="flex gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all rounded-md items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
<div className={"relative"}>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20"></span>
|
||||
{updateIcon}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<div
|
||||
className={
|
||||
" inline-flex gap-2 items-center rounded-md text-xs my-2"
|
||||
}
|
||||
>
|
||||
<MemoizedNetBirdIcon />
|
||||
<span>{version}</span>
|
||||
<ArrowRightIcon size={16} className={"text-netbird"} />
|
||||
<span className={"text-netbird"}>{latestVersion}</span>
|
||||
</div>
|
||||
<p className={"font-medium"}>Update available </p>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
|
||||
}
|
||||
>
|
||||
A new version of Netbird is available. Please update your client
|
||||
to get the latest features and bug fixes.
|
||||
</div>
|
||||
<InlineLink
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
href={latestUrl as string}
|
||||
target={"_blank"}
|
||||
className={"mt-2 mb-2 text-xs"}
|
||||
>
|
||||
Download & Changelog
|
||||
</InlineLink>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div className="inline-flex gap-2 dark:text-neutral-300 text-neutral-500 items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{os && os !== "" && os !== " " && (
|
||||
<FullTooltip
|
||||
delayDuration={500}
|
||||
disabled={!serial || serial === ""}
|
||||
content={
|
||||
<div className={"text-xs"}>
|
||||
<span className={"text-nb-gray-100 font-medium"}>Serial: </span>
|
||||
{serial}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
" inline-flex gap-2 items-center rounded-md text-xs my-2"
|
||||
"flex items-center gap-2 text-neutral-300 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<MemoizedNetBirdIcon />
|
||||
<span>{version}</span>
|
||||
<ArrowRightIcon size={16} className={"text-netbird"} />
|
||||
<span className={"text-netbird"}>{latestVersion}</span>
|
||||
</div>
|
||||
<p className={"font-medium"}>Update available </p>
|
||||
<PeerOperatingSystemIcon os={os} />
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
|
||||
}
|
||||
>
|
||||
A new version of Netbird is available. Please update your client to
|
||||
get the latest features and bug fixes.
|
||||
{isWasmClient ? "Web Client" : os}
|
||||
</div>
|
||||
<InlineLink
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
href={latestUrl as string}
|
||||
target={"_blank"}
|
||||
className={"mt-2 mb-2 text-xs"}
|
||||
>
|
||||
Download & Changelog
|
||||
</InlineLink>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div className="inline-flex gap-2 dark:text-neutral-300 text-neutral-500 py-2 px-3 items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
</FullTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
import DataTableHeader from "@components/table/DataTableHeader";
|
||||
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
|
||||
import {
|
||||
ColumnDef,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { uniqBy } from "lodash";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { trim, uniqBy } from "lodash";
|
||||
import { MonitorDotIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeerProvider from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
@@ -30,6 +28,7 @@ import { Peer } from "@/interfaces/Peer";
|
||||
import { GroupFilterSelector } from "@/modules/groups/GroupFilterSelector";
|
||||
import PeerActionCell from "@/modules/peers/PeerActionCell";
|
||||
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
|
||||
import { PeerConnectButton } from "@/modules/peers/PeerConnectButton";
|
||||
import PeerGroupCell from "@/modules/peers/PeerGroupCell";
|
||||
import PeerLastSeenCell from "@/modules/peers/PeerLastSeenCell";
|
||||
import { PeerMultiSelect } from "@/modules/peers/PeerMultiSelect";
|
||||
@@ -72,6 +71,16 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerNameCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "connect",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<PeerProvider peer={row.original}>
|
||||
<PeerConnectButton />
|
||||
</PeerProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
accessorKey: "approval_required",
|
||||
@@ -157,7 +166,11 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
return <DataTableHeader column={column}>Version</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<PeerVersionCell version={row.original.version} os={row.original.os} />
|
||||
<PeerVersionCell
|
||||
version={row.original.version}
|
||||
os={row.original.os}
|
||||
serial={row.original.serial_number}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -243,6 +256,30 @@ export default function PeersTable({
|
||||
}
|
||||
};
|
||||
|
||||
const [showBrowserPeers, setShowBrowserPeers] = useState(false);
|
||||
|
||||
const withBrowserPeers = useCallback(
|
||||
(condition: boolean) =>
|
||||
peers?.filter((peer) =>
|
||||
condition ? trim(peer.os) === "js" : trim(peer.os) !== "js",
|
||||
) ?? [],
|
||||
[peers],
|
||||
);
|
||||
|
||||
const browserPeers = useMemo(() => {
|
||||
return withBrowserPeers(true);
|
||||
}, [withBrowserPeers]);
|
||||
|
||||
const regularPeers = useMemo(() => {
|
||||
return withBrowserPeers(false);
|
||||
}, [withBrowserPeers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showBrowserPeers && browserPeers?.length === 0) {
|
||||
setShowBrowserPeers(false);
|
||||
}
|
||||
}, [showBrowserPeers, browserPeers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PeerMultiSelect
|
||||
@@ -258,7 +295,7 @@ export default function PeersTable({
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={peers}
|
||||
data={showBrowserPeers ? browserPeers : regularPeers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
columnVisibility={{
|
||||
select: permission.groups.read,
|
||||
@@ -271,38 +308,12 @@ export default function PeersTable({
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
actions: permission.peers.update,
|
||||
connect: permission.peers.update,
|
||||
groups: permission.groups.read,
|
||||
os: false,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Get Started with NetBird"}
|
||||
description={
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Getting Started Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
getStartedCard={<NoPeersGettingStarted showBackground={true} />}
|
||||
rightSide={() => <>{peers && peers.length > 0 && <AddPeerButton />}</>}
|
||||
>
|
||||
{(table) => (
|
||||
@@ -471,6 +482,28 @@ export default function PeersTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
{browserPeers?.length > 0 && (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-sm text-xs"}>
|
||||
Show temporary peers created by the NetBird browser client.
|
||||
These peers are ephemeral and will be deleted automatically
|
||||
after a short period of time.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={"h-[44px]"}
|
||||
variant={showBrowserPeers ? "tertiary" : "secondary"}
|
||||
onClick={() => {
|
||||
setShowBrowserPeers(!showBrowserPeers);
|
||||
}}
|
||||
>
|
||||
<MonitorDotIcon size={16} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
)}
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
|
||||
72
src/modules/remote-access/rdp/RDPButton.tsx
Normal file
72
src/modules/remote-access/rdp/RDPButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@components/Button";
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
|
||||
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
isDropdown?: boolean;
|
||||
};
|
||||
|
||||
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const disabled = !peer.connected || !permission.peers.update;
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
const isWindows = getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS;
|
||||
|
||||
const openRDPPage = () => {
|
||||
window.open(
|
||||
`peer/rdp?id=${peer.id}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=1200,height=650,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
isWindows && (
|
||||
<>
|
||||
<div>
|
||||
<RDPTooltip
|
||||
disabled={!disabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<MonitorIcon size={14} className={"shrink-0"} />
|
||||
RDP
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MonitorIcon size={16} />
|
||||
RDP
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</RDPTooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
170
src/modules/remote-access/rdp/RDPCertificateModal.tsx
Normal file
170
src/modules/remote-access/rdp/RDPCertificateModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Separator from "@components/Separator";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CertificateInfo,
|
||||
CertificatePromptInfo,
|
||||
} from "./useRDPCertificateHandler";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
certificateInfo: CertificatePromptInfo | null;
|
||||
onAccept: (remember: boolean) => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
export const RDPCertificateModal = ({
|
||||
open,
|
||||
certificateInfo,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: Props) => {
|
||||
const [rememberCertificate, setRememberCertificate] = useState(false);
|
||||
if (!certificateInfo) return null;
|
||||
const { hostname, certificate, isChange } = certificateInfo;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={undefined}>
|
||||
<ModalContent maxWidthClass={"max-w-2xl"} showClose={false}>
|
||||
<ModalHeader
|
||||
icon={<LockIcon className={"text-netbird"} size={18} />}
|
||||
title={"RDP Certificate"}
|
||||
description={hostname}
|
||||
color={"netbird"}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-6"}>
|
||||
{isChange && (
|
||||
<Callout variant={"warning"}>
|
||||
Warning! Certificate has changed. Only proceed if you trust this
|
||||
connection.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Certificate Details</Label>
|
||||
<HelpText>
|
||||
Certificated could not be verified by a trusted authority. Review
|
||||
the certificate information before proceeding with the connection.
|
||||
</HelpText>
|
||||
<CertificateDetailsList certificate={certificate} />
|
||||
</div>
|
||||
|
||||
<label className={"flex items-center space-x-3 cursor-pointer"}>
|
||||
<Checkbox
|
||||
id="remember-cert"
|
||||
checked={rememberCertificate}
|
||||
variant={"tableCell"}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberCertificate(checked === true)
|
||||
}
|
||||
/>
|
||||
<div className={"font-normal text-sm text-nb-gray-200"}>
|
||||
Always trust{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
{'"' + certificate?.issuer?.replace("CN=", "") + '"'}
|
||||
</span>{" "}
|
||||
when connecting to{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
{'"' + hostname + '"'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<Button variant={"secondary"} onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => onAccept(rememberCertificate)}
|
||||
>
|
||||
Accept & Continue
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateDetailsList = ({
|
||||
certificate,
|
||||
}: {
|
||||
certificate: CertificateInfo;
|
||||
}) => {
|
||||
if (!certificate) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-930 border border-nb-gray-900 rounded-md mt-3 flex flex-col py-3 px-4 gap-2"
|
||||
}
|
||||
>
|
||||
<CertificateDetailsListItem
|
||||
label={"Issuer"}
|
||||
value={certificate.issuer || "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Subject"}
|
||||
value={certificate.subject || "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Valid From"}
|
||||
value={
|
||||
certificate.validFrom
|
||||
? new Date(certificate.validFrom).toLocaleString()
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Valid To"}
|
||||
value={
|
||||
certificate.validTo
|
||||
? new Date(certificate.validTo).toLocaleString()
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Key Size"}
|
||||
value={certificate.keySize ? `${certificate.keySize} bits` : "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Serial Number"}
|
||||
value={certificate.serialNumber || "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Fingerprint"}
|
||||
value={certificate.fingerprint || "N/A"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateDetailsListItem = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => {
|
||||
return (
|
||||
<div key={label} className={"flex justify-between text-xs gap-10"}>
|
||||
<span className={"font-mono text-nb-gray-200 w-[200px]"}>{label}:</span>
|
||||
<span className={"font-mono text-nb-gray-300 break-all text-left w-full"}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
src/modules/remote-access/rdp/RDPCredentialsModal.tsx
Normal file
200
src/modules/remote-access/rdp/RDPCredentialsModal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
KeyRoundIcon,
|
||||
MonitorIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import Separator from "@components/Separator";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Button from "@components/Button";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import {
|
||||
RDP_DOCS_LINK,
|
||||
RDPCredentials,
|
||||
} from "@/modules/remote-access/rdp/useRemoteDesktop";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
peer: Peer;
|
||||
onConnect?: (credentials: RDPCredentials) => void;
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const RDPCredentialsModal = ({
|
||||
open,
|
||||
peer,
|
||||
onConnect,
|
||||
error,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const [username, setUsername] = useState("Administrator");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [port, setPort] = useState("3389");
|
||||
|
||||
const userNameError = useMemo(() => {
|
||||
if (username?.length === 0) return "Username cannot be empty";
|
||||
}, [username]);
|
||||
|
||||
const portError = useMemo(() => {
|
||||
const portNumber = Number(port);
|
||||
const isValid =
|
||||
Number.isInteger(portNumber) && portNumber > 0 && portNumber <= 65535;
|
||||
if (!isValid) return "Port must be a number between 1 and 65535";
|
||||
}, [port]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
if (userNameError !== undefined) return true;
|
||||
return portError !== undefined;
|
||||
}, [userNameError, portError]);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (hasAnyError || !onConnect) return;
|
||||
onConnect({
|
||||
username,
|
||||
password,
|
||||
port: Number(port),
|
||||
});
|
||||
}, [hasAnyError, onConnect, username, password, port]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !hasAnyError && !loading) {
|
||||
handleConnect();
|
||||
}
|
||||
},
|
||||
[handleConnect, hasAnyError, loading],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={undefined}>
|
||||
<ModalContent maxWidthClass={"max-w-xl"} showClose={false}>
|
||||
<ModalHeader
|
||||
icon={<MonitorIcon className={"text-netbird"} size={18} />}
|
||||
title={peer.name}
|
||||
description={`Connect to ${peer.ip} via RDP`}
|
||||
color={"netbird"}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<form
|
||||
className={"px-8 py-6 flex flex-col gap-8"}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className={"bg-red-50 border border-red-200 rounded-md p-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 text-red-800 font-medium mb-1"
|
||||
}
|
||||
>
|
||||
Error
|
||||
</div>
|
||||
<p className={"text-sm text-red-700"}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Username & Password</Label>
|
||||
<HelpText>
|
||||
Enter the credentials required to authenticate with the remote
|
||||
host.
|
||||
</HelpText>
|
||||
<div className={"flex flex-col gap-2 w-full"}>
|
||||
<Input
|
||||
placeholder={"Administrator"}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
name="username"
|
||||
autoComplete={"username"}
|
||||
error={userNameError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
customPrefix={
|
||||
<User2 size={16} className={"text-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={password}
|
||||
placeholder={"Enter password"}
|
||||
type={"password"}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
name="password"
|
||||
autoComplete={"current-password"}
|
||||
error={undefined}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
customPrefix={
|
||||
<KeyRoundIcon size={16} className={"text-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Port</Label>
|
||||
<HelpText>
|
||||
Specify the RDP port for your remote connection.
|
||||
</HelpText>
|
||||
<Input
|
||||
maxWidthClass={""}
|
||||
placeholder={"3389"}
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
type={"number"}
|
||||
error={portError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
customPrefix={
|
||||
<ChevronsLeftRightEllipsis
|
||||
size={16}
|
||||
className={"text-nb-gray-300"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={RDP_DOCS_LINK} target={"_blank"}>
|
||||
RDP
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary"}
|
||||
disabled={hasAnyError || loading}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{loading && <IconLoader2 size={16} className={"animate-spin"} />}
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
37
src/modules/remote-access/rdp/RDPTooltip.tsx
Normal file
37
src/modules/remote-access/rdp/RDPTooltip.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
hasPermission?: boolean;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
export const RDPTooltip = ({
|
||||
disabled,
|
||||
children,
|
||||
hasPermission,
|
||||
side = "top",
|
||||
}: Props) => {
|
||||
return (
|
||||
<FullTooltip
|
||||
className={"w-full"}
|
||||
side={side}
|
||||
content={
|
||||
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
|
||||
{hasPermission ? (
|
||||
<div>This peer is offline and cannot be accessed via RDP.</div>
|
||||
) : (
|
||||
<div>
|
||||
You do not have permission to launch an RDP session. Please
|
||||
contact your administrator.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
809
src/modules/remote-access/rdp/ironrdp-input-handler.ts
Normal file
809
src/modules/remote-access/rdp/ironrdp-input-handler.ts
Normal file
@@ -0,0 +1,809 @@
|
||||
/**
|
||||
* IronRDP Input Handler for NetBird WASM Client
|
||||
* Handles mouse and keyboard input for IronRDP sessions
|
||||
*/
|
||||
import type { IronRDPModule, RDPSession } from "./ironrdp-wasm-bridge";
|
||||
|
||||
interface DeviceEvent {
|
||||
free?(): void;
|
||||
}
|
||||
interface InputTransaction {
|
||||
addEvent(event: DeviceEvent): void;
|
||||
free?(): void;
|
||||
}
|
||||
interface IronRDPAPI extends IronRDPModule {
|
||||
DeviceEvent: {
|
||||
mouseButtonPressed(button: number): DeviceEvent;
|
||||
mouseButtonReleased(button: number): DeviceEvent;
|
||||
mouseMove(x: number, y: number): DeviceEvent;
|
||||
wheelRotations(isVertical: boolean, rotationUnits: number): DeviceEvent;
|
||||
keyPressed(scancode: number): DeviceEvent;
|
||||
keyReleased(scancode: number): DeviceEvent;
|
||||
unicode(code: number): DeviceEvent;
|
||||
};
|
||||
InputTransaction: new () => InputTransaction;
|
||||
}
|
||||
interface ExtendedRDPSession extends RDPSession {
|
||||
applyInputs(transaction: InputTransaction): void;
|
||||
}
|
||||
interface CoordinateResult {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
toggleFullscreen?: () => void;
|
||||
}
|
||||
}
|
||||
export class IronRDPInputHandler {
|
||||
private ironrdp: IronRDPAPI;
|
||||
private session: ExtendedRDPSession;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private isActive = false;
|
||||
private mouseButtonStates: Record<number, boolean> = {
|
||||
0: false,
|
||||
1: false,
|
||||
2: false,
|
||||
};
|
||||
private keyStates = new Map<string, boolean>();
|
||||
private currentMouseX = 0;
|
||||
private currentMouseY = 0;
|
||||
|
||||
// Bound event handlers for proper cleanup
|
||||
private boundHandlers = {
|
||||
mouseDown: this.handleMouseDown.bind(this),
|
||||
mouseUp: this.handleMouseUp.bind(this),
|
||||
mouseMove: this.handleMouseMove.bind(this),
|
||||
mouseEnter: this.handleMouseEnter.bind(this),
|
||||
wheel: this.handleWheel.bind(this),
|
||||
touchStart: this.handleTouchStart.bind(this),
|
||||
touchMove: this.handleTouchMove.bind(this),
|
||||
touchEnd: this.handleTouchEnd.bind(this),
|
||||
keyDown: this.handleKeyDown.bind(this),
|
||||
keyUp: this.handleKeyUp.bind(this),
|
||||
paste: this.handlePaste.bind(this),
|
||||
copy: this.handleCopy.bind(this),
|
||||
contextMenu: (e: Event) => e.preventDefault(),
|
||||
focus: this.handleFocus.bind(this),
|
||||
blur: this.handleBlur.bind(this),
|
||||
click: this.handleClick.bind(this),
|
||||
globalKeyDown: this.handleGlobalKeyDown.bind(this),
|
||||
};
|
||||
// Keyboard code to scancode mappings (using event.code instead of deprecated keyCode)
|
||||
private readonly codeToScancode: Record<string, number> = {
|
||||
// Letters
|
||||
KeyA: 0x1e,
|
||||
KeyB: 0x30,
|
||||
KeyC: 0x2e,
|
||||
KeyD: 0x20,
|
||||
KeyE: 0x12,
|
||||
KeyF: 0x21,
|
||||
KeyG: 0x22,
|
||||
KeyH: 0x23,
|
||||
KeyI: 0x17,
|
||||
KeyJ: 0x24,
|
||||
KeyK: 0x25,
|
||||
KeyL: 0x26,
|
||||
KeyM: 0x32,
|
||||
KeyN: 0x31,
|
||||
KeyO: 0x18,
|
||||
KeyP: 0x19,
|
||||
KeyQ: 0x10,
|
||||
KeyR: 0x13,
|
||||
KeyS: 0x1f,
|
||||
KeyT: 0x14,
|
||||
KeyU: 0x16,
|
||||
KeyV: 0x2f,
|
||||
KeyW: 0x11,
|
||||
KeyX: 0x2d,
|
||||
KeyY: 0x15,
|
||||
KeyZ: 0x2c,
|
||||
// Numbers
|
||||
Digit0: 0x0b,
|
||||
Digit1: 0x02,
|
||||
Digit2: 0x03,
|
||||
Digit3: 0x04,
|
||||
Digit4: 0x05,
|
||||
Digit5: 0x06,
|
||||
Digit6: 0x07,
|
||||
Digit7: 0x08,
|
||||
Digit8: 0x09,
|
||||
Digit9: 0x0a,
|
||||
// Function keys
|
||||
F1: 0x3b,
|
||||
F2: 0x3c,
|
||||
F3: 0x3d,
|
||||
F4: 0x3e,
|
||||
F5: 0x3f,
|
||||
F6: 0x40,
|
||||
F7: 0x41,
|
||||
F8: 0x42,
|
||||
F9: 0x43,
|
||||
F10: 0x44,
|
||||
F11: 0x57,
|
||||
F12: 0x58,
|
||||
// Special keys
|
||||
Backspace: 0x0e,
|
||||
Tab: 0x0f,
|
||||
Enter: 0x1c,
|
||||
ShiftLeft: 0x2a,
|
||||
ShiftRight: 0x36,
|
||||
ControlLeft: 0x1d,
|
||||
ControlRight: 0x9d,
|
||||
AltLeft: 0x38,
|
||||
AltRight: 0xb8,
|
||||
CapsLock: 0x3a,
|
||||
Escape: 0x01,
|
||||
Space: 0x39,
|
||||
PageUp: 0xe049,
|
||||
PageDown: 0xe051,
|
||||
End: 0xe04f,
|
||||
Home: 0xe047,
|
||||
ArrowLeft: 0xe04b,
|
||||
ArrowUp: 0xe048,
|
||||
ArrowRight: 0xe04d,
|
||||
ArrowDown: 0xe050,
|
||||
Insert: 0xe052,
|
||||
Delete: 0xe053,
|
||||
MetaLeft: this.isMacOS() ? 0x1d : 0x5b,
|
||||
MetaRight: this.isMacOS() ? 0x9d : 0x5c,
|
||||
// Punctuation
|
||||
Semicolon: 0x27,
|
||||
Equal: 0x0d,
|
||||
Comma: 0x33,
|
||||
Minus: 0x0c,
|
||||
Period: 0x34,
|
||||
Slash: 0x35,
|
||||
Backquote: 0x29,
|
||||
BracketLeft: 0x1a,
|
||||
Backslash: 0x2b,
|
||||
BracketRight: 0x1b,
|
||||
Quote: 0x28,
|
||||
// Numpad keys
|
||||
Numpad0: 0x52,
|
||||
Numpad1: 0x4f,
|
||||
Numpad2: 0x50,
|
||||
Numpad3: 0x51,
|
||||
Numpad4: 0x4b,
|
||||
Numpad5: 0x4c,
|
||||
Numpad6: 0x4d,
|
||||
Numpad7: 0x47,
|
||||
Numpad8: 0x48,
|
||||
Numpad9: 0x49,
|
||||
NumpadDecimal: 0x53,
|
||||
NumpadDivide: 0xe035,
|
||||
NumpadMultiply: 0x37,
|
||||
NumpadSubtract: 0x4a,
|
||||
NumpadAdd: 0x4e,
|
||||
NumpadEnter: 0xe01c,
|
||||
NumLock: 0x45,
|
||||
// System keys
|
||||
PrintScreen: 0xe037,
|
||||
ScrollLock: 0x46,
|
||||
Pause: 0xe11d,
|
||||
// Additional Windows/Context keys
|
||||
ContextMenu: 0xe05d,
|
||||
// Additional function keys (F13-F24)
|
||||
F13: 0x64,
|
||||
F14: 0x65,
|
||||
F15: 0x66,
|
||||
F16: 0x67,
|
||||
F17: 0x68,
|
||||
F18: 0x69,
|
||||
F19: 0x6a,
|
||||
F20: 0x6b,
|
||||
F21: 0x6c,
|
||||
F22: 0x6d,
|
||||
F23: 0x6e,
|
||||
F24: 0x76,
|
||||
// Media keys
|
||||
AudioVolumeDown: 0xe02e,
|
||||
AudioVolumeUp: 0xe030,
|
||||
AudioVolumeMute: 0xe020,
|
||||
MediaPlayPause: 0xe022,
|
||||
MediaStop: 0xe024,
|
||||
MediaTrackPrevious: 0xe010,
|
||||
MediaTrackNext: 0xe019,
|
||||
// Browser/Application keys
|
||||
BrowserBack: 0xe06a,
|
||||
BrowserForward: 0xe069,
|
||||
BrowserRefresh: 0xe067,
|
||||
BrowserStop: 0xe068,
|
||||
BrowserSearch: 0xe065,
|
||||
BrowserFavorites: 0xe066,
|
||||
BrowserHome: 0xe032,
|
||||
LaunchMail: 0xe06c,
|
||||
LaunchApp1: 0xe06b,
|
||||
LaunchApp2: 0xe021,
|
||||
// International keys
|
||||
IntlBackslash: 0x56,
|
||||
IntlRo: 0x73,
|
||||
IntlYen: 0x7d,
|
||||
};
|
||||
private readonly mouseButtonMap: Record<number, number> = {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
};
|
||||
private touchState = {
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
touching: false,
|
||||
};
|
||||
constructor(
|
||||
ironrdp: IronRDPModule,
|
||||
session: RDPSession,
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
this.ironrdp = ironrdp as IronRDPAPI;
|
||||
this.session = session as ExtendedRDPSession;
|
||||
this.canvas = canvas;
|
||||
this.setupEventListeners();
|
||||
// Initialize mouse position to unknown - will be set on first mouse event
|
||||
this.currentMouseX = -1;
|
||||
this.currentMouseY = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the current platform is macOS
|
||||
*/
|
||||
private isMacOS(): boolean {
|
||||
if ("userAgentData" in navigator && (navigator as any).userAgentData) {
|
||||
return (navigator as any).userAgentData.platform === "macOS";
|
||||
}
|
||||
// Fallback
|
||||
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
/**
|
||||
* Calculate canvas coordinates with letterbox correction for fullscreen mode
|
||||
*/
|
||||
private getCanvasCoordinates(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): CoordinateResult {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
// Calculate the actual rendered size of the canvas content
|
||||
const canvasAspectRatio = this.canvas.width / this.canvas.height;
|
||||
const containerAspectRatio = rect.width / rect.height;
|
||||
let renderWidth: number,
|
||||
renderHeight: number,
|
||||
offsetX: number,
|
||||
offsetY: number;
|
||||
// Check if we're using object-fit: contain (letterboxing)
|
||||
const isFullscreen =
|
||||
document.fullscreenElement === this.canvas ||
|
||||
document.fullscreenElement === this.canvas.parentElement;
|
||||
const hasLetterbox = isFullscreen && this.canvas.style.objectFit !== "fill";
|
||||
if (hasLetterbox && canvasAspectRatio !== containerAspectRatio) {
|
||||
// Calculate actual rendered dimensions with letterboxing
|
||||
if (canvasAspectRatio > containerAspectRatio) {
|
||||
// Canvas is wider - letterbox on top/bottom
|
||||
renderWidth = rect.width;
|
||||
renderHeight = rect.width / canvasAspectRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (rect.height - renderHeight) / 2;
|
||||
} else {
|
||||
// Canvas is taller - letterbox on left/right
|
||||
renderWidth = rect.height * canvasAspectRatio;
|
||||
renderHeight = rect.height;
|
||||
offsetX = (rect.width - renderWidth) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
} else {
|
||||
// No letterboxing - canvas fills the entire rect
|
||||
renderWidth = rect.width;
|
||||
renderHeight = rect.height;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
}
|
||||
// Calculate scale factors based on actual render size
|
||||
const scaleX = this.canvas.width / renderWidth;
|
||||
const scaleY = this.canvas.height / renderHeight;
|
||||
// Adjust coordinates for letterbox offset
|
||||
const relativeX = clientX - rect.left - offsetX;
|
||||
const relativeY = clientY - rect.top - offsetY;
|
||||
// Clamp to valid canvas area
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(this.canvas.width - 1, Math.round(relativeX * scaleX)),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(this.canvas.height - 1, Math.round(relativeY * scaleY)),
|
||||
);
|
||||
return { x, y };
|
||||
}
|
||||
private setupEventListeners(): void {
|
||||
this.canvas.tabIndex = 1;
|
||||
this.canvas.style.outline = "none";
|
||||
|
||||
// Mouse events
|
||||
this.canvas.addEventListener("mousedown", this.boundHandlers.mouseDown);
|
||||
this.canvas.addEventListener("mouseup", this.boundHandlers.mouseUp);
|
||||
this.canvas.addEventListener("mousemove", this.boundHandlers.mouseMove);
|
||||
this.canvas.addEventListener("mouseenter", this.boundHandlers.mouseEnter);
|
||||
this.canvas.addEventListener("wheel", this.boundHandlers.wheel);
|
||||
this.canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu);
|
||||
|
||||
// Touch events
|
||||
this.canvas.addEventListener("touchstart", this.boundHandlers.touchStart);
|
||||
this.canvas.addEventListener("touchmove", this.boundHandlers.touchMove);
|
||||
this.canvas.addEventListener("touchend", this.boundHandlers.touchEnd);
|
||||
|
||||
// Keyboard events
|
||||
this.canvas.addEventListener("keydown", this.boundHandlers.keyDown);
|
||||
this.canvas.addEventListener("keyup", this.boundHandlers.keyUp);
|
||||
this.canvas.addEventListener("paste", this.boundHandlers.paste);
|
||||
this.canvas.addEventListener("copy", this.boundHandlers.copy);
|
||||
|
||||
// Focus events
|
||||
this.canvas.addEventListener("focus", this.boundHandlers.focus);
|
||||
this.canvas.addEventListener("blur", this.boundHandlers.blur);
|
||||
this.canvas.addEventListener("click", this.boundHandlers.click);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener("keydown", this.boundHandlers.globalKeyDown);
|
||||
}
|
||||
private updateVisualIndicator(active: boolean): void {
|
||||
if (!this.canvas.parentElement) return;
|
||||
const controls = this.canvas.parentElement.querySelector(
|
||||
"#rdpControls",
|
||||
) as HTMLElement;
|
||||
if (!controls) return;
|
||||
controls.style.borderBottom = active ? "2px solid #4CAF50" : "none";
|
||||
}
|
||||
private handleGlobalKeyDown(e: KeyboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
// F11 for fullscreen toggle
|
||||
if (e.key === "F11") {
|
||||
e.preventDefault();
|
||||
if (window.toggleFullscreen) {
|
||||
window.toggleFullscreen();
|
||||
}
|
||||
}
|
||||
// Ctrl+Alt+Enter for fullscreen toggle
|
||||
else if (e.ctrlKey && e.altKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (window.toggleFullscreen) {
|
||||
window.toggleFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleFocus(): void {
|
||||
this.isActive = true;
|
||||
this.updateVisualIndicator(true);
|
||||
// Trigger clipboard sync when canvas gains focus
|
||||
this.requestClipboardSync();
|
||||
}
|
||||
|
||||
private handleBlur(): void {
|
||||
this.isActive = false;
|
||||
this.releaseAllKeys();
|
||||
this.updateVisualIndicator(false);
|
||||
}
|
||||
|
||||
private handleClick(): void {
|
||||
this.canvas.focus();
|
||||
// Also sync clipboard on click to ensure paste works
|
||||
this.requestClipboardSync();
|
||||
}
|
||||
|
||||
private handlePaste(event: ClipboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// Only prevent default if we successfully handle the paste
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
const text = clipboardData.getData("text/plain");
|
||||
if (!text) return;
|
||||
|
||||
// Prevent the default paste behavior only after we have the text
|
||||
event.preventDefault();
|
||||
|
||||
// Send Ctrl+V combination to RDP session first to maintain consistency
|
||||
this.sendPasteKeyCombination();
|
||||
|
||||
// Then send the actual text content
|
||||
this.sendTextAsKeystrokes(text);
|
||||
}
|
||||
|
||||
private handleCopy(event: ClipboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// Send Ctrl+C combination to RDP session
|
||||
this.sendCopyKeyCombination();
|
||||
|
||||
// Let the browser handle the actual copy operation
|
||||
// Don't prevent default - we want the browser to copy from the RDP canvas
|
||||
}
|
||||
|
||||
private sendPasteKeyCombination(): void {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
|
||||
try {
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Send Ctrl (or Cmd on Mac) key down
|
||||
const ctrlScancode = this.isMacOS()
|
||||
? this.codeToScancode.MetaLeft
|
||||
: this.codeToScancode.ControlLeft;
|
||||
const vScancode = this.codeToScancode.KeyV;
|
||||
|
||||
if (ctrlScancode && vScancode) {
|
||||
// Ctrl/Cmd down
|
||||
const ctrlDown = this.ironrdp.DeviceEvent.keyPressed(ctrlScancode);
|
||||
transaction.addEvent(ctrlDown);
|
||||
|
||||
// V down
|
||||
const vDown = this.ironrdp.DeviceEvent.keyPressed(vScancode);
|
||||
transaction.addEvent(vDown);
|
||||
|
||||
// V up
|
||||
const vUp = this.ironrdp.DeviceEvent.keyReleased(vScancode);
|
||||
transaction.addEvent(vUp);
|
||||
|
||||
// Ctrl/Cmd up
|
||||
const ctrlUp = this.ironrdp.DeviceEvent.keyReleased(ctrlScancode);
|
||||
transaction.addEvent(ctrlUp);
|
||||
|
||||
this.session.applyInputs(transaction);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error sending paste key combination:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private sendCopyKeyCombination(): void {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
|
||||
try {
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Send Ctrl (or Cmd on Mac) key down
|
||||
const ctrlScancode = this.isMacOS()
|
||||
? this.codeToScancode.MetaLeft
|
||||
: this.codeToScancode.ControlLeft;
|
||||
const cScancode = this.codeToScancode.KeyC;
|
||||
|
||||
if (ctrlScancode && cScancode) {
|
||||
// Ctrl/Cmd down
|
||||
const ctrlDown = this.ironrdp.DeviceEvent.keyPressed(ctrlScancode);
|
||||
transaction.addEvent(ctrlDown);
|
||||
|
||||
// C down
|
||||
const cDown = this.ironrdp.DeviceEvent.keyPressed(cScancode);
|
||||
transaction.addEvent(cDown);
|
||||
|
||||
// C up
|
||||
const cUp = this.ironrdp.DeviceEvent.keyReleased(cScancode);
|
||||
transaction.addEvent(cUp);
|
||||
|
||||
// Ctrl/Cmd up
|
||||
const ctrlUp = this.ironrdp.DeviceEvent.keyReleased(ctrlScancode);
|
||||
transaction.addEvent(ctrlUp);
|
||||
|
||||
this.session.applyInputs(transaction);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error sending copy key combination:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private sendTextAsKeystrokes(text: string): void {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
|
||||
try {
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Send each character as unicode event
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.unicode(charCode);
|
||||
transaction.addEvent(deviceEvent);
|
||||
}
|
||||
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending paste text:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private requestClipboardSync(): void {
|
||||
// Notify the WASM bridge to check and sync clipboard, only for chrome for now, firefox and safari have issues with this
|
||||
if (!/Chrome/.test(navigator.userAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
window.IronRDPBridge &&
|
||||
(window.IronRDPBridge as any).checkAndSendClipboard
|
||||
) {
|
||||
setTimeout(() => {
|
||||
(window.IronRDPBridge as any).checkAndSendClipboard();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
private handleMouseDown(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.canvas.focus();
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
const { x, y } = this.getCanvasCoordinates(event.clientX, event.clientY);
|
||||
const button = this.mouseButtonMap[event.button];
|
||||
if (button === undefined) return;
|
||||
if (this.mouseButtonStates[event.button]) return;
|
||||
this.mouseButtonStates[event.button] = true;
|
||||
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Always send mouse position first, then the button press
|
||||
const moveEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const clickEvent = this.ironrdp.DeviceEvent.mouseButtonPressed(button);
|
||||
|
||||
transaction.addEvent(moveEvent);
|
||||
transaction.addEvent(clickEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
} catch (err) {
|
||||
console.error("Error sending mouse down:", err);
|
||||
}
|
||||
}
|
||||
private handleMouseUp(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
const button = this.mouseButtonMap[event.button];
|
||||
if (button === undefined) return;
|
||||
if (!this.mouseButtonStates[event.button]) return;
|
||||
this.mouseButtonStates[event.button] = false;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseButtonReleased(button);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending mouse up:", err);
|
||||
}
|
||||
}
|
||||
private handleMouseEnter(event: MouseEvent): void {
|
||||
// Always sync position when entering
|
||||
this.handleMouseMove(event);
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent): void {
|
||||
const { x, y } = this.getCanvasCoordinates(event.clientX, event.clientY);
|
||||
|
||||
// Skip if position hasn't changed
|
||||
if (x === this.currentMouseX && y === this.currentMouseY) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending mouse move:", err);
|
||||
}
|
||||
}
|
||||
private handleWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.isActive) return;
|
||||
// Calculate rotation units (120 units = 1 notch)
|
||||
const delta = event.deltaY > 0 ? -1 : 1;
|
||||
const rotationUnits = delta * 120;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.wheelRotations(
|
||||
true,
|
||||
rotationUnits,
|
||||
);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending wheel event:", err);
|
||||
}
|
||||
}
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// For clipboard operations, don't prevent default to allow clipboard events to fire
|
||||
const isClipboardPaste =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v";
|
||||
const isClipboardCopy =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c";
|
||||
|
||||
if (!isClipboardPaste && !isClipboardCopy) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Don't send clipboard combinations here - let the clipboard events handle them (only for Chromium browsers)
|
||||
const isChromium = /Chrome/.test(navigator.userAgent);
|
||||
if ((isClipboardPaste || isClipboardCopy) && isChromium) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scancode = this.codeToScancode[event.code];
|
||||
if (scancode !== undefined) {
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.keyPressed(scancode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending key down:", err);
|
||||
}
|
||||
} else if (event.key.length === 1) {
|
||||
// For printable characters not in our scancode map, use unicode
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const charCode = event.key.charCodeAt(0);
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.unicode(charCode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending unicode char:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyUp(event: KeyboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// For clipboard operations, don't prevent default to allow clipboard events to fire
|
||||
const isClipboardPaste =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v";
|
||||
const isClipboardCopy =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c";
|
||||
|
||||
if (!isClipboardPaste && !isClipboardCopy) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Don't send clipboard combinations here - let the clipboard events handle them
|
||||
const isChromium = /Chrome/.test(navigator.userAgent);
|
||||
if ((isClipboardPaste || isClipboardCopy) && isChromium) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scancode = this.codeToScancode[event.code];
|
||||
if (scancode === undefined) return;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.keyReleased(scancode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending key up:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleTouchStart(event: TouchEvent): void {
|
||||
event.preventDefault();
|
||||
if (event.touches.length !== 1) return;
|
||||
const touch = event.touches[0];
|
||||
const { x, y } = this.getCanvasCoordinates(touch.clientX, touch.clientY);
|
||||
this.touchState.lastX = x;
|
||||
this.touchState.lastY = y;
|
||||
this.touchState.touching = true;
|
||||
// Simulate mouse down
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const moveEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const clickEvent = this.ironrdp.DeviceEvent.mouseButtonPressed(0);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(moveEvent);
|
||||
transaction.addEvent(clickEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
} catch (err) {
|
||||
console.error("Error handling touch start:", err);
|
||||
}
|
||||
}
|
||||
private handleTouchMove(event: TouchEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.touchState.touching || event.touches.length !== 1) return;
|
||||
const touch = event.touches[0];
|
||||
const { x, y } = this.getCanvasCoordinates(touch.clientX, touch.clientY);
|
||||
if (x === this.touchState.lastX && y === this.touchState.lastY) return;
|
||||
this.touchState.lastX = x;
|
||||
this.touchState.lastY = y;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
} catch (err) {
|
||||
console.error("Error handling touch move:", err);
|
||||
}
|
||||
}
|
||||
private handleTouchEnd(event: TouchEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.touchState.touching) return;
|
||||
this.touchState.touching = false;
|
||||
// Simulate mouse up
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseButtonReleased(0);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error handling touch end:", err);
|
||||
}
|
||||
}
|
||||
private releaseAllKeys(): void {
|
||||
this.keyStates.forEach((pressed, code) => {
|
||||
if (!pressed) return;
|
||||
const scancode = this.codeToScancode[code];
|
||||
if (scancode === undefined) return;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.keyReleased(scancode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error releasing key:", err);
|
||||
}
|
||||
});
|
||||
this.keyStates.clear();
|
||||
}
|
||||
destroy(): void {
|
||||
// Remove all event listeners using the same bound functions
|
||||
this.canvas.removeEventListener("mousedown", this.boundHandlers.mouseDown);
|
||||
this.canvas.removeEventListener("mouseup", this.boundHandlers.mouseUp);
|
||||
this.canvas.removeEventListener("mousemove", this.boundHandlers.mouseMove);
|
||||
this.canvas.removeEventListener(
|
||||
"mouseenter",
|
||||
this.boundHandlers.mouseEnter,
|
||||
);
|
||||
this.canvas.removeEventListener("wheel", this.boundHandlers.wheel);
|
||||
this.canvas.removeEventListener(
|
||||
"contextmenu",
|
||||
this.boundHandlers.contextMenu,
|
||||
);
|
||||
this.canvas.removeEventListener(
|
||||
"touchstart",
|
||||
this.boundHandlers.touchStart,
|
||||
);
|
||||
this.canvas.removeEventListener("touchmove", this.boundHandlers.touchMove);
|
||||
this.canvas.removeEventListener("touchend", this.boundHandlers.touchEnd);
|
||||
this.canvas.removeEventListener("keydown", this.boundHandlers.keyDown);
|
||||
this.canvas.removeEventListener("keyup", this.boundHandlers.keyUp);
|
||||
this.canvas.removeEventListener("paste", this.boundHandlers.paste);
|
||||
this.canvas.removeEventListener("copy", this.boundHandlers.copy);
|
||||
this.canvas.removeEventListener("focus", this.boundHandlers.focus);
|
||||
this.canvas.removeEventListener("blur", this.boundHandlers.blur);
|
||||
this.canvas.removeEventListener("click", this.boundHandlers.click);
|
||||
document.removeEventListener("keydown", this.boundHandlers.globalKeyDown);
|
||||
|
||||
this.releaseAllKeys();
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).IronRDPInputHandler = IronRDPInputHandler;
|
||||
}
|
||||
450
src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts
Normal file
450
src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
export interface IronRDPModule {
|
||||
SessionBuilder: new () => SessionBuilder;
|
||||
DesktopSize: new (width: number, height: number) => DesktopSize;
|
||||
ClipboardData?: new () => ClipboardData;
|
||||
default?: () => Promise<void>;
|
||||
init?: () => Promise<void>;
|
||||
}
|
||||
interface DesktopSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface SessionBuilder {
|
||||
username(user: string): SessionBuilder;
|
||||
password(pwd: string): SessionBuilder;
|
||||
destination(dest: string): SessionBuilder;
|
||||
serverDomain(domain: string): SessionBuilder;
|
||||
desktopSize(size: DesktopSize): SessionBuilder;
|
||||
renderCanvas(canvas: HTMLCanvasElement): SessionBuilder;
|
||||
proxyAddress(url: string): SessionBuilder;
|
||||
authToken(token: string): SessionBuilder;
|
||||
setCursorStyleCallback(cb: (style: string) => void): void;
|
||||
setCursorStyleCallbackContext(ctx: unknown): void;
|
||||
remoteClipboardChangedCallback(cb: (data: ClipboardData) => void): void;
|
||||
forceClipboardUpdateCallback(cb: () => void): void;
|
||||
connect(): Promise<RDPSession>;
|
||||
}
|
||||
export interface RDPSession {
|
||||
run(): Promise<TerminationInfo>;
|
||||
shutdown(): void;
|
||||
sendInput(input: unknown): void;
|
||||
onClipboardPaste?(content: ClipboardData): Promise<void>;
|
||||
inputHandler?: IronRDPInputHandler;
|
||||
}
|
||||
interface TerminationInfo {
|
||||
reason(): string;
|
||||
}
|
||||
interface ClipboardData {
|
||||
items(): ClipboardItem[];
|
||||
addText(mimeType: string, text: string): void;
|
||||
addBinary(mimeType: string, binary: Uint8Array): void;
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
interface ClipboardItem {
|
||||
mimeType(): string;
|
||||
value(): string;
|
||||
}
|
||||
interface RDPConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
domain?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
enable_tls: boolean;
|
||||
enable_credssp: boolean;
|
||||
enable_nla: boolean;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
IronRDPBridge: IronRDPWASMBridge;
|
||||
IronRDPInputHandler?: new (
|
||||
ironrdp: IronRDPModule,
|
||||
session: RDPSession,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => IronRDPInputHandler;
|
||||
initializeIronRDP: () => Promise<boolean>;
|
||||
onIronRDPReady?: () => void;
|
||||
createRDCleanPathProxy?: (
|
||||
hostname: string,
|
||||
port: number,
|
||||
) => Promise<string>;
|
||||
}
|
||||
}
|
||||
interface IronRDPInputHandler {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
|
||||
|
||||
export class IronRDPWASMBridge {
|
||||
private ironrdp: IronRDPModule | null = null;
|
||||
private initialized = false;
|
||||
private sessions = new Map<string, RDPSession>();
|
||||
private lastClipboardContent = "";
|
||||
private clipboardEventListeners: (() => void)[] = [];
|
||||
|
||||
// Expose clipboard sync method for input handler
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
try {
|
||||
// @ts-ignore - Dynamic import from public directory
|
||||
const ironrdpModule = (await import(
|
||||
/* webpackIgnore: true */ IRON_RDP_PKG
|
||||
)) as IronRDPModule;
|
||||
try {
|
||||
if (ironrdpModule.default) {
|
||||
await ironrdpModule.default();
|
||||
}
|
||||
} catch (e) {
|
||||
if (ironrdpModule.init) {
|
||||
await ironrdpModule.init();
|
||||
}
|
||||
}
|
||||
this.ironrdp = ironrdpModule;
|
||||
this.initialized = true;
|
||||
if (window.onIronRDPReady) {
|
||||
window.onIronRDPReady();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load IronRDP WASM:", error);
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
async connect(
|
||||
hostname: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
canvas: HTMLCanvasElement,
|
||||
enableClipboard = true,
|
||||
netbirdClient?: {
|
||||
createRDPProxy: (hostname: string, port: string) => Promise<string>;
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
if (!this.ironrdp) {
|
||||
throw new Error("IronRDP module not loaded");
|
||||
}
|
||||
const sessionId = `${hostname}:${port}_${Date.now()}`;
|
||||
try {
|
||||
const config: RDPConfig = {
|
||||
username,
|
||||
password,
|
||||
domain: "",
|
||||
width: canvas.width || 1024,
|
||||
height: canvas.height || 768,
|
||||
enable_tls: true,
|
||||
enable_credssp: true,
|
||||
enable_nla: true,
|
||||
};
|
||||
const builder = new this.ironrdp.SessionBuilder();
|
||||
builder
|
||||
.username(username)
|
||||
.password(password)
|
||||
.destination(`${hostname}:${port}`);
|
||||
if (config.domain) {
|
||||
builder.serverDomain(config.domain);
|
||||
}
|
||||
const desktopSize = new this.ironrdp.DesktopSize(
|
||||
config.width,
|
||||
config.height,
|
||||
);
|
||||
builder.desktopSize(desktopSize);
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
}
|
||||
builder.renderCanvas(canvas);
|
||||
}
|
||||
builder.setCursorStyleCallback((style: string) => {});
|
||||
builder.setCursorStyleCallbackContext(null);
|
||||
if (enableClipboard) {
|
||||
this.setupClipboard(builder);
|
||||
}
|
||||
// RDCleanPath proxy is required for IronRDP
|
||||
if (!netbirdClient || !netbirdClient.createRDPProxy) {
|
||||
throw new Error("NetBird client with RDP proxy support is required");
|
||||
}
|
||||
const proxyURL = await netbirdClient.createRDPProxy(
|
||||
hostname,
|
||||
port.toString(),
|
||||
);
|
||||
builder.proxyAddress(proxyURL);
|
||||
builder.authToken("");
|
||||
const session = await builder.connect();
|
||||
this.sessions.set(sessionId, session);
|
||||
if (canvas) {
|
||||
this.attachInputHandler(session, canvas);
|
||||
}
|
||||
if (enableClipboard) {
|
||||
this.startClipboardEventListeners();
|
||||
}
|
||||
this.startSession(session, sessionId);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error(`IronRDP connection failed:`, error);
|
||||
this.logIronError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private setupClipboard(builder: SessionBuilder): void {
|
||||
if (!this.ironrdp?.ClipboardData) {
|
||||
console.warn("ClipboardData class not available in IronRDP module");
|
||||
return;
|
||||
}
|
||||
builder.remoteClipboardChangedCallback((clipboardData: ClipboardData) => {
|
||||
this.handleRemoteClipboard(clipboardData);
|
||||
});
|
||||
builder.forceClipboardUpdateCallback(() => {
|
||||
this.handleLocalClipboardRequest();
|
||||
});
|
||||
}
|
||||
private attachInputHandler(
|
||||
session: RDPSession,
|
||||
canvas: HTMLCanvasElement,
|
||||
): void {
|
||||
if (!window.IronRDPInputHandler) {
|
||||
console.warn("IronRDPInputHandler not loaded - input will not work");
|
||||
return;
|
||||
}
|
||||
if (!this.ironrdp) {
|
||||
console.warn("IronRDP module not available");
|
||||
return;
|
||||
}
|
||||
session.inputHandler = new window.IronRDPInputHandler(
|
||||
this.ironrdp,
|
||||
session,
|
||||
canvas,
|
||||
);
|
||||
}
|
||||
private startSession(session: RDPSession, sessionId: string): void {
|
||||
session
|
||||
.run()
|
||||
.then((termInfo) => {
|
||||
this.cleanupSession(session, sessionId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("IronRDP session error:", err);
|
||||
this.cleanupSession(session, sessionId);
|
||||
throw Error(err);
|
||||
});
|
||||
}
|
||||
private cleanupSession(session: RDPSession, sessionId: string): void {
|
||||
if (session.inputHandler) {
|
||||
session.inputHandler.destroy();
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// Stop clipboard event listeners if no active sessions
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopClipboardEventListeners();
|
||||
}
|
||||
}
|
||||
private logIronError(error: unknown): void {
|
||||
const ironError = error as any;
|
||||
if (!ironError || !ironError.__wbg_ptr) return;
|
||||
try {
|
||||
if (ironError.backtrace) {
|
||||
console.error("IronRDP backtrace:", ironError.backtrace());
|
||||
}
|
||||
if (ironError.kind) {
|
||||
const errorKind = ironError.kind();
|
||||
const errorKindNames = [
|
||||
"General",
|
||||
"WrongPassword",
|
||||
"LogonFailure",
|
||||
"AccessDenied",
|
||||
"RDCleanPath",
|
||||
"ProxyConnect",
|
||||
"NegotiationFailure",
|
||||
];
|
||||
const errorKindName = errorKindNames[errorKind] || "Unknown";
|
||||
console.error("IronRDP error kind:", errorKindName, `(${errorKind})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Could not extract IronError details:", e);
|
||||
}
|
||||
}
|
||||
disconnect(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (session.inputHandler) {
|
||||
session.inputHandler.destroy();
|
||||
session.inputHandler = undefined;
|
||||
}
|
||||
if (session.shutdown) {
|
||||
session.shutdown();
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// Stop clipboard event listeners if no active sessions
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopClipboardEventListeners();
|
||||
}
|
||||
}
|
||||
private handleRemoteClipboard(clipboardData: ClipboardData): void {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
console.warn("Browser clipboard API not available");
|
||||
return;
|
||||
}
|
||||
if (!clipboardData.items) {
|
||||
console.error("clipboardData.items() method not found");
|
||||
return;
|
||||
}
|
||||
const items = clipboardData.items();
|
||||
if (items.length === 0) return;
|
||||
for (const item of items) {
|
||||
const mimeType = item.mimeType();
|
||||
const value = item.value();
|
||||
if (mimeType !== "text/plain") {
|
||||
continue;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => {
|
||||
//this.showClipboardNotification("Clipboard updated from RDP");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to copy to browser clipboard:", err);
|
||||
this.fallbackClipboardCopy(value);
|
||||
});
|
||||
return; // Only handle first text item
|
||||
}
|
||||
}
|
||||
private fallbackClipboardCopy(text: string): void {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const success = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (success) {
|
||||
//this.showClipboardNotification("Clipboard updated from RDP");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fallback clipboard error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLocalClipboardRequest(): Promise<void> {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
console.warn("Browser clipboard read API not available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
if (clipboardText && clipboardText !== this.lastClipboardContent) {
|
||||
await this.sendClipboardToRDP(clipboardText);
|
||||
this.lastClipboardContent = clipboardText;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Could not read from clipboard:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendClipboardToRDP(text: string): Promise<void> {
|
||||
if (!this.ironrdp?.ClipboardData) return;
|
||||
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
try {
|
||||
const clipboardData = new this.ironrdp.ClipboardData();
|
||||
clipboardData.addText("text/plain", text);
|
||||
|
||||
if (session.onClipboardPaste) {
|
||||
await session.onClipboardPaste(clipboardData);
|
||||
}
|
||||
//this.showClipboardNotification("Clipboard sent to RDP");
|
||||
} catch (err) {
|
||||
console.error("Failed to send clipboard to RDP:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
private startClipboardEventListeners(): void {
|
||||
if (this.clipboardEventListeners.length > 0) return;
|
||||
|
||||
// Listen for keyboard shortcuts (Ctrl+C, Ctrl+V, Ctrl+X)
|
||||
const handleKeyboardShortcut = async (event: KeyboardEvent) => {
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
["c", "x", "v"].includes(event.key.toLowerCase())
|
||||
) {
|
||||
// For copy/cut operations, check clipboard after delay
|
||||
if (["c", "x"].includes(event.key.toLowerCase())) {
|
||||
setTimeout(async () => {
|
||||
await this.checkAndSendClipboard();
|
||||
}, 100);
|
||||
}
|
||||
// For paste, check immediately to ensure up-to-date content
|
||||
else if (event.key.toLowerCase() === "v") {
|
||||
await this.checkAndSendClipboard();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for clipboard events (more reliable when available)
|
||||
const handleClipboardChange = async () => {
|
||||
await this.checkAndSendClipboard();
|
||||
};
|
||||
|
||||
// Listen for focus events - check clipboard when window regains focus
|
||||
const handleFocus = async () => {
|
||||
await this.checkAndSendClipboard();
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener("keydown", handleKeyboardShortcut);
|
||||
document.addEventListener("copy", handleClipboardChange);
|
||||
document.addEventListener("cut", handleClipboardChange);
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
// Store cleanup functions
|
||||
this.clipboardEventListeners = [
|
||||
() => document.removeEventListener("keydown", handleKeyboardShortcut),
|
||||
() => document.removeEventListener("copy", handleClipboardChange),
|
||||
() => document.removeEventListener("cut", handleClipboardChange),
|
||||
() => window.removeEventListener("focus", handleFocus),
|
||||
];
|
||||
}
|
||||
|
||||
private stopClipboardEventListeners(): void {
|
||||
this.clipboardEventListeners.forEach((cleanup) => cleanup());
|
||||
this.clipboardEventListeners = [];
|
||||
}
|
||||
|
||||
public async checkAndSendClipboard(): Promise<void> {
|
||||
if (!navigator.clipboard?.readText) return;
|
||||
|
||||
if (!/Chrome/.test(navigator.userAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
if (clipboardText && clipboardText !== this.lastClipboardContent) {
|
||||
await this.sendClipboardToRDP(clipboardText);
|
||||
this.lastClipboardContent = clipboardText;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore clipboard read errors - might be due to focus/permission issues
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.IronRDPBridge = new IronRDPWASMBridge();
|
||||
window.initializeIronRDP = async function (): Promise<boolean> {
|
||||
try {
|
||||
await window.IronRDPBridge.initialize();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize IronRDP:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
397
src/modules/remote-access/rdp/rdp-certificate-handler.ts
Normal file
397
src/modules/remote-access/rdp/rdp-certificate-handler.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* RDP Certificate Handler
|
||||
* Handles X.509 certificate validation and user acceptance for RDP connections
|
||||
*/
|
||||
export interface CertificateInfo {
|
||||
raw?: Uint8Array;
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
validFrom?: Date;
|
||||
validTo?: Date;
|
||||
serialNumber?: string;
|
||||
keySize?: number;
|
||||
}
|
||||
interface TrustedCertificate {
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
addedAt: string;
|
||||
subject?: string;
|
||||
}
|
||||
export interface RDCleanPathResponse {
|
||||
ServerAddr?: string;
|
||||
ServerCertChain?: Uint8Array[];
|
||||
CertificateInfo?: CertificateInfo;
|
||||
}
|
||||
|
||||
export interface CertificateHandler {
|
||||
validateCertificate(certInfo: CertificateInfo, hostname?: string): Promise<boolean>;
|
||||
handleRDCleanPathResponse(response: RDCleanPathResponse): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class RDPCertificateHandler implements CertificateHandler {
|
||||
private readonly STORAGE_KEY = 'netbird-rdp-trusted-certs';
|
||||
private modalElement: HTMLElement | null = null;
|
||||
/**
|
||||
* Handle RDCleanPath response containing server certificates
|
||||
*/
|
||||
async handleRDCleanPathResponse(response: RDCleanPathResponse): Promise<boolean> {
|
||||
if (!response.ServerCertChain || response.ServerCertChain.length === 0) {
|
||||
console.error('No certificate chain provided - rejecting connection for security');
|
||||
return false;
|
||||
}
|
||||
const serverAddr = response.ServerAddr || 'unknown';
|
||||
const hostname = serverAddr.split(':')[0];
|
||||
const certBytes = response.ServerCertChain[0];
|
||||
try {
|
||||
// Check if response already has parsed certificate info from Go proxy
|
||||
if (response.CertificateInfo) {
|
||||
return await this.validateCertificate(response.CertificateInfo, hostname);
|
||||
}
|
||||
// Fallback to parsing the raw certificate
|
||||
const certInfo = await this.parseCertificate(certBytes, hostname);
|
||||
return await this.validateCertificate(certInfo, hostname);
|
||||
} catch (error) {
|
||||
console.error('Certificate validation error:', error);
|
||||
return await this.promptRawCertificateAcceptance(certBytes, hostname);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Parse X.509 certificate bytes to extract relevant information
|
||||
* Note: For proper X.509 parsing, the Go proxy should provide parsed certificate info
|
||||
*/
|
||||
async parseCertificate(certBytes: Uint8Array, hostname: string): Promise<CertificateInfo> {
|
||||
const fingerprint = await this.calculateFingerprint(certBytes);
|
||||
// Basic certificate info - actual parsing should be done by Go proxy
|
||||
const certInfo: CertificateInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint: fingerprint,
|
||||
hostname: hostname
|
||||
};
|
||||
// Try to extract basic info from certificate if not provided by proxy
|
||||
// This is a fallback - proper X.509 parsing should be done server-side
|
||||
const certString = new TextDecoder('latin1').decode(certBytes);
|
||||
const cnMatch = certString.match(/CN=([^,\0]+)/);
|
||||
if (cnMatch) {
|
||||
certInfo.subject = cnMatch[0];
|
||||
}
|
||||
return certInfo;
|
||||
}
|
||||
/**
|
||||
* Extract serial number from certificate bytes
|
||||
* Note: This is a placeholder - actual serial number extraction requires proper ASN.1 parsing
|
||||
*/
|
||||
private extractSerialNumber(certBytes: Uint8Array): string | undefined {
|
||||
// Serial number extraction should be done by the Go proxy
|
||||
// This is just a fallback that won't work reliably
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Calculate SHA-256 fingerprint of certificate
|
||||
*/
|
||||
async calculateFingerprint(certBytes: Uint8Array): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', certBytes);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(':')
|
||||
.toUpperCase();
|
||||
}
|
||||
/**
|
||||
* Validate certificate against stored trust database
|
||||
*/
|
||||
async validateCertificate(certInfo: CertificateInfo, hostname?: string): Promise<boolean> {
|
||||
const host = hostname || certInfo.hostname;
|
||||
const trustedCerts = this.loadTrustedCerts();
|
||||
if (trustedCerts[host]) {
|
||||
const stored = trustedCerts[host];
|
||||
if (stored.fingerprint === certInfo.fingerprint) {
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`Certificate for ${host} has changed!`);
|
||||
return await this.promptCertificateChange(host, certInfo, stored);
|
||||
}
|
||||
}
|
||||
console.log(`New certificate for ${host} - requesting user acceptance`);
|
||||
return await this.promptUserAcceptance(host, certInfo);
|
||||
}
|
||||
/**
|
||||
* Load trusted certificates from storage
|
||||
*/
|
||||
private loadTrustedCerts(): Record<string, TrustedCertificate> {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (error) {
|
||||
console.error('Failed to load trusted certificates:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save trusted certificate to storage
|
||||
*/
|
||||
private saveTrustedCert(hostname: string, certInfo: CertificateInfo): void {
|
||||
try {
|
||||
const trustedCerts = this.loadTrustedCerts();
|
||||
trustedCerts[hostname] = {
|
||||
fingerprint: certInfo.fingerprint,
|
||||
hostname: hostname,
|
||||
addedAt: new Date().toISOString(),
|
||||
subject: certInfo.subject
|
||||
};
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(trustedCerts));
|
||||
} catch (error) {
|
||||
console.error('Failed to save trusted certificate:', error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Prompt user to accept a new certificate
|
||||
*/
|
||||
private async promptUserAcceptance(hostname: string, certInfo: CertificateInfo): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = this.createCertificateModal(hostname, certInfo, false);
|
||||
const acceptBtn = modal.querySelector('#cert-accept') as HTMLButtonElement;
|
||||
const rejectBtn = modal.querySelector('#cert-reject') as HTMLButtonElement;
|
||||
const rememberCheck = modal.querySelector('#cert-remember') as HTMLInputElement;
|
||||
acceptBtn.onclick = () => {
|
||||
const remember = rememberCheck.checked;
|
||||
if (remember) {
|
||||
this.saveTrustedCert(hostname, certInfo);
|
||||
}
|
||||
this.closeModal();
|
||||
resolve(true);
|
||||
};
|
||||
rejectBtn.onclick = () => {
|
||||
this.closeModal();
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Prompt user when certificate has changed
|
||||
*/
|
||||
private async promptCertificateChange(
|
||||
hostname: string,
|
||||
newCert: CertificateInfo,
|
||||
oldCert: TrustedCertificate
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = this.createCertificateModal(hostname, newCert, true);
|
||||
// Add warning about certificate change
|
||||
const warningDiv = modal.querySelector('.cert-warning') as HTMLElement;
|
||||
warningDiv.innerHTML = `
|
||||
<strong>⚠️ Certificate has changed!</strong><br>
|
||||
<small>Previous fingerprint: ${oldCert.fingerprint.substring(0, 32)}...</small>
|
||||
`;
|
||||
const acceptBtn = modal.querySelector('#cert-accept') as HTMLButtonElement;
|
||||
const rejectBtn = modal.querySelector('#cert-reject') as HTMLButtonElement;
|
||||
const rememberCheck = modal.querySelector('#cert-remember') as HTMLInputElement;
|
||||
acceptBtn.onclick = () => {
|
||||
const remember = rememberCheck.checked;
|
||||
if (remember) {
|
||||
this.saveTrustedCert(hostname, newCert);
|
||||
}
|
||||
this.closeModal();
|
||||
resolve(true);
|
||||
};
|
||||
rejectBtn.onclick = () => {
|
||||
this.closeModal();
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Prompt for raw certificate acceptance when parsing fails
|
||||
*/
|
||||
private async promptRawCertificateAcceptance(certBytes: Uint8Array, hostname: string): Promise<boolean> {
|
||||
const fingerprint = await this.calculateFingerprint(certBytes);
|
||||
const certInfo: CertificateInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint: fingerprint,
|
||||
hostname: hostname,
|
||||
subject: 'Unable to parse',
|
||||
issuer: 'Unable to parse'
|
||||
};
|
||||
return this.promptUserAcceptance(hostname, certInfo);
|
||||
}
|
||||
/**
|
||||
* Create certificate acceptance modal
|
||||
*/
|
||||
private createCertificateModal(hostname: string, certInfo: CertificateInfo, isChange: boolean): HTMLElement {
|
||||
// Remove any existing modal
|
||||
this.closeModal();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'rdp-cert-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="rdp-cert-overlay"></div>
|
||||
<div class="rdp-cert-dialog">
|
||||
<h2>RDP Certificate Verification</h2>
|
||||
<div class="cert-warning" style="color: #ff9800; margin-bottom: 15px;"></div>
|
||||
<p>The server <strong>${hostname}</strong> is presenting a certificate:</p>
|
||||
<div class="cert-details">
|
||||
<table>
|
||||
<tr><td><strong>Subject:</strong></td><td>${certInfo.subject || 'Unknown'}</td></tr>
|
||||
<tr><td><strong>Issuer:</strong></td><td>${certInfo.issuer || 'Unknown'}</td></tr>
|
||||
${certInfo.serialNumber ? `<tr><td><strong>Serial:</strong></td><td style="font-family: monospace; font-size: 0.9em;">${certInfo.serialNumber}</td></tr>` : ''}
|
||||
<tr><td><strong>SHA-256:</strong></td><td style="font-family: monospace; font-size: 0.9em;">
|
||||
${certInfo.fingerprint}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cert-question">
|
||||
<p>Do you trust this certificate?</p>
|
||||
<label>
|
||||
<input type="checkbox" id="cert-remember" checked>
|
||||
Remember this certificate for future connections
|
||||
</label>
|
||||
</div>
|
||||
<div class="cert-buttons">
|
||||
<button id="cert-reject" class="cert-btn cert-btn-reject">Reject</button>
|
||||
<button id="cert-accept" class="cert-btn cert-btn-accept">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Add styles if not already present
|
||||
if (!document.querySelector('#rdp-cert-styles')) {
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'rdp-cert-styles';
|
||||
styles.textContent = this.getModalStyles();
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
document.body.appendChild(modal);
|
||||
this.modalElement = modal;
|
||||
return modal;
|
||||
}
|
||||
/**
|
||||
* Close the certificate modal
|
||||
*/
|
||||
private closeModal(): void {
|
||||
if (this.modalElement) {
|
||||
document.body.removeChild(this.modalElement);
|
||||
this.modalElement = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get modal CSS styles
|
||||
*/
|
||||
private getModalStyles(): string {
|
||||
return `
|
||||
.rdp-cert-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10000;
|
||||
}
|
||||
.rdp-cert-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.rdp-cert-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.rdp-cert-dialog h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #0078d4;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.cert-details {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.cert-details table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.cert-details td {
|
||||
padding: 5px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cert-details td:first-child {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.cert-question {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.cert-question label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cert-question input {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.cert-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.cert-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.cert-btn-accept {
|
||||
background: #0078d4;
|
||||
color: white;
|
||||
}
|
||||
.cert-btn-accept:hover {
|
||||
background: #106ebe;
|
||||
}
|
||||
.cert-btn-reject {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
.cert-btn-reject:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
/**
|
||||
* Clear all trusted certificates
|
||||
*/
|
||||
clearTrustedCerts(): void {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
console.log('Cleared all trusted RDP certificates');
|
||||
}
|
||||
/**
|
||||
* Get list of trusted certificates
|
||||
*/
|
||||
getTrustedCerts(): TrustedCertificate[] {
|
||||
const trustedCerts = this.loadTrustedCerts();
|
||||
return Object.values(trustedCerts);
|
||||
}
|
||||
}
|
||||
// Export as global for compatibility
|
||||
declare global {
|
||||
interface Window {
|
||||
RDPCertificateHandler: typeof RDPCertificateHandler;
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.RDPCertificateHandler = RDPCertificateHandler;
|
||||
}
|
||||
320
src/modules/remote-access/rdp/useRDPCertificateHandler.ts
Normal file
320
src/modules/remote-access/rdp/useRDPCertificateHandler.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export interface CertificateInfo {
|
||||
raw?: Uint8Array;
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
validFrom?: Date;
|
||||
validTo?: Date;
|
||||
serialNumber?: string;
|
||||
keySize?: number;
|
||||
}
|
||||
|
||||
interface TrustedCertificate {
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
addedAt: string;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export interface RDCleanPathResponse {
|
||||
ServerAddr?: string;
|
||||
ServerCertChain?: Uint8Array[];
|
||||
CertificateInfo?: CertificateInfo;
|
||||
}
|
||||
|
||||
export interface CertificatePromptInfo {
|
||||
hostname: string;
|
||||
certificate: CertificateInfo;
|
||||
isChange: boolean;
|
||||
previousCertificate?: TrustedCertificate;
|
||||
}
|
||||
|
||||
export interface CertificateValidationResult {
|
||||
isValid: boolean;
|
||||
needsUserConfirmation: boolean;
|
||||
promptInfo?: CertificatePromptInfo;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "netbird-rdp-trusted-certs";
|
||||
|
||||
export const useRDPCertificateHandler = () => {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const calculateFingerprint = useCallback(
|
||||
async (certBytes: Uint8Array): Promise<string> => {
|
||||
try {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", certBytes);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const fingerprint = hashArray
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(":")
|
||||
.toUpperCase();
|
||||
return fingerprint;
|
||||
} catch (error) {
|
||||
return "FINGERPRINT_CALCULATION_FAILED";
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const parseCertificate = useCallback(
|
||||
async (
|
||||
certBytes: Uint8Array,
|
||||
hostname: string,
|
||||
): Promise<CertificateInfo> => {
|
||||
const fingerprint = await calculateFingerprint(certBytes);
|
||||
const certInfo: CertificateInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint,
|
||||
hostname,
|
||||
};
|
||||
|
||||
try {
|
||||
const certString = new TextDecoder("latin1").decode(certBytes);
|
||||
|
||||
// Parse subject (CN)
|
||||
const cnMatch = certString.match(/CN=([^,\0\x00-\x1f]+)/);
|
||||
if (cnMatch) {
|
||||
certInfo.subject = `CN=${cnMatch[1].trim()}`;
|
||||
}
|
||||
|
||||
// Parse issuer - look for issuer field
|
||||
const issuerMatch = certString.match(
|
||||
/(?:issuer|Issuer).*?CN=([^,\0\x00-\x1f]+)/i,
|
||||
);
|
||||
if (issuerMatch) {
|
||||
certInfo.issuer = `CN=${issuerMatch[1].trim()}`;
|
||||
} else {
|
||||
// Fallback: look for second CN occurrence (often issuer)
|
||||
const cnMatches = [...certString.matchAll(/CN=([^,\0\x00-\x1f]+)/g)];
|
||||
if (cnMatches.length > 1) {
|
||||
certInfo.issuer = `CN=${cnMatches[1][1].trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate key size based on certificate structure and length
|
||||
if (certBytes.length > 100) {
|
||||
// Look for RSA signature patterns or use length heuristic
|
||||
if (certBytes.length > 1400) {
|
||||
certInfo.keySize = 2048;
|
||||
} else if (certBytes.length > 1000) {
|
||||
certInfo.keySize = 1024;
|
||||
} else if (certBytes.length > 600) {
|
||||
certInfo.keySize = 1024;
|
||||
} else {
|
||||
certInfo.keySize = 512;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse serial number (look for sequence of hex bytes)
|
||||
const serialMatch = certString.match(
|
||||
/[\x02][\x01-\x10]([\x00-\xff]{1,16})/,
|
||||
);
|
||||
if (serialMatch && serialMatch[1]) {
|
||||
const serialBytes = Array.from(serialMatch[1], (char) =>
|
||||
char.charCodeAt(0),
|
||||
);
|
||||
certInfo.serialNumber = serialBytes
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(":")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// Try to parse validity dates (basic ASN.1 time format)
|
||||
// Look for UTCTime or GeneralizedTime patterns
|
||||
const timePattern =
|
||||
/[\x17\x18][\x0d\x0f](\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z?/g;
|
||||
const timeMatches = [...certString.matchAll(timePattern)];
|
||||
|
||||
if (timeMatches.length >= 2) {
|
||||
const parseTime = (match: RegExpMatchArray) => {
|
||||
let year = parseInt(match[1]);
|
||||
// Handle 2-digit years (UTCTime format)
|
||||
if (year < 50) year += 2000;
|
||||
else if (year < 100) year += 1900;
|
||||
|
||||
const month = parseInt(match[2]) - 1; // JS months are 0-based
|
||||
const day = parseInt(match[3]);
|
||||
const hour = parseInt(match[4]);
|
||||
const minute = parseInt(match[5]);
|
||||
const second = parseInt(match[6]);
|
||||
|
||||
return new Date(year, month, day, hour, minute, second);
|
||||
};
|
||||
|
||||
certInfo.validFrom = parseTime(timeMatches[0]);
|
||||
certInfo.validTo = parseTime(timeMatches[1]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse certificate details:", error);
|
||||
}
|
||||
|
||||
return certInfo;
|
||||
},
|
||||
[calculateFingerprint],
|
||||
);
|
||||
|
||||
const getTrustedCerts = useCallback((): Record<
|
||||
string,
|
||||
TrustedCertificate
|
||||
> => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (error) {
|
||||
console.error("Failed to load trusted certificates:", error);
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveTrustedCert = useCallback(
|
||||
(hostname: string, certInfo: CertificateInfo): void => {
|
||||
try {
|
||||
const trustedCerts = getTrustedCerts();
|
||||
trustedCerts[hostname] = {
|
||||
fingerprint: certInfo.fingerprint,
|
||||
hostname,
|
||||
addedAt: new Date().toISOString(),
|
||||
subject: certInfo.subject,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trustedCerts));
|
||||
} catch (error) {
|
||||
console.error("Failed to save trusted certificate:", error);
|
||||
}
|
||||
},
|
||||
[getTrustedCerts],
|
||||
);
|
||||
|
||||
const validateCertificate = useCallback(
|
||||
async (
|
||||
certInfo: CertificateInfo,
|
||||
hostname?: string,
|
||||
): Promise<CertificateValidationResult> => {
|
||||
const host = hostname || certInfo.hostname;
|
||||
const trustedCerts = getTrustedCerts();
|
||||
const stored = trustedCerts[host];
|
||||
|
||||
if (stored) {
|
||||
if (stored.fingerprint === certInfo.fingerprint) {
|
||||
return { isValid: true, needsUserConfirmation: false };
|
||||
}
|
||||
|
||||
console.warn(`Certificate for ${host} has changed!`);
|
||||
return {
|
||||
isValid: false,
|
||||
needsUserConfirmation: true,
|
||||
promptInfo: {
|
||||
hostname: host,
|
||||
certificate: certInfo,
|
||||
isChange: true,
|
||||
previousCertificate: stored,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
needsUserConfirmation: true,
|
||||
promptInfo: {
|
||||
hostname: host,
|
||||
certificate: certInfo,
|
||||
isChange: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
[getTrustedCerts],
|
||||
);
|
||||
|
||||
const handleRDCleanPathResponse = useCallback(
|
||||
async (
|
||||
response: RDCleanPathResponse,
|
||||
): Promise<CertificateValidationResult> => {
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
if (!response.ServerCertChain?.length) {
|
||||
return { isValid: false, needsUserConfirmation: false };
|
||||
}
|
||||
|
||||
const serverAddr = response.ServerAddr || "unknown";
|
||||
const hostname = serverAddr.split(":")[0];
|
||||
const certBytes = response.ServerCertChain[0];
|
||||
|
||||
let certInfo: CertificateInfo;
|
||||
|
||||
if (response.CertificateInfo) {
|
||||
certInfo = response.CertificateInfo;
|
||||
// Add missing fingerprint and keySize that the server doesn't provide
|
||||
if (!certInfo.fingerprint) {
|
||||
certInfo.fingerprint = await calculateFingerprint(certBytes);
|
||||
}
|
||||
if (!certInfo.keySize && certBytes.length > 100) {
|
||||
if (certBytes.length > 1400) {
|
||||
certInfo.keySize = 2048;
|
||||
} else if (certBytes.length > 1000) {
|
||||
certInfo.keySize = 1024;
|
||||
} else if (certBytes.length > 600) {
|
||||
certInfo.keySize = 1024;
|
||||
} else {
|
||||
certInfo.keySize = 512;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
certInfo = await parseCertificate(certBytes, hostname);
|
||||
} catch (error) {
|
||||
console.error("Certificate parsing error:", error);
|
||||
const fingerprint = await calculateFingerprint(certBytes);
|
||||
certInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint,
|
||||
hostname,
|
||||
subject: "Unable to parse",
|
||||
issuer: "Unable to parse",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return await validateCertificate(certInfo, hostname);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
},
|
||||
[parseCertificate, validateCertificate, calculateFingerprint],
|
||||
);
|
||||
|
||||
const acceptCertificate = useCallback(
|
||||
(
|
||||
hostname: string,
|
||||
certInfo: CertificateInfo,
|
||||
remember: boolean = true,
|
||||
): void => {
|
||||
if (remember) {
|
||||
saveTrustedCert(hostname, certInfo);
|
||||
}
|
||||
},
|
||||
[saveTrustedCert],
|
||||
);
|
||||
|
||||
const clearTrustedCerts = useCallback((): void => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
const listTrustedCerts = useCallback((): TrustedCertificate[] => {
|
||||
return Object.values(getTrustedCerts());
|
||||
}, [getTrustedCerts]);
|
||||
|
||||
return {
|
||||
isValidating,
|
||||
handleRDCleanPathResponse,
|
||||
validateCertificate,
|
||||
acceptCertificate,
|
||||
clearTrustedCerts,
|
||||
getTrustedCerts: listTrustedCerts,
|
||||
calculateFingerprint,
|
||||
parseCertificate,
|
||||
};
|
||||
};
|
||||
58
src/modules/remote-access/rdp/useRDPQueryParams.ts
Normal file
58
src/modules/remote-access/rdp/useRDPQueryParams.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface RDPQueryParams {
|
||||
peerId: string | null;
|
||||
}
|
||||
|
||||
export function useRDPQueryParams() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<RDPQueryParams>({
|
||||
peerId: null,
|
||||
});
|
||||
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
|
||||
|
||||
useEffect(() => {
|
||||
const peerId = searchParams.get("id");
|
||||
|
||||
// If all params are present in URL, use them
|
||||
if (peerId) {
|
||||
setParams({ peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to restore from localStorage
|
||||
const storedParams = localStorage.getItem("netbird-query-params");
|
||||
if (!storedParams) return;
|
||||
|
||||
// Handle JSON encoded strings from localStorage
|
||||
let paramsString = storedParams;
|
||||
if (storedParams.startsWith('"') && storedParams.endsWith('"')) {
|
||||
try {
|
||||
paramsString = JSON.parse(storedParams);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(paramsString);
|
||||
const storedPeerId = urlParams.get("id");
|
||||
|
||||
if (storedPeerId) {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("id", storedPeerId);
|
||||
|
||||
router.replace(`/peer/rdp?${newSearchParams.toString()}`);
|
||||
setParams({
|
||||
peerId: storedPeerId,
|
||||
});
|
||||
|
||||
// Clear stored params after restoring
|
||||
setLocalQueryParams("");
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
return params;
|
||||
}
|
||||
326
src/modules/remote-access/rdp/useRemoteDesktop.ts
Normal file
326
src/modules/remote-access/rdp/useRemoteDesktop.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CertificatePromptInfo,
|
||||
useRDPCertificateHandler,
|
||||
} from "./useRDPCertificateHandler";
|
||||
|
||||
interface IronError {
|
||||
message: string;
|
||||
backtrace?: () => string;
|
||||
}
|
||||
|
||||
interface RDPConfig {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface RDPCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface RDPConnection {
|
||||
id: string;
|
||||
disconnect: (options?: {
|
||||
preserveConfig?: boolean;
|
||||
preserveCertificateState?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export enum RDPStatus {
|
||||
DISCONNECTED = 0,
|
||||
CONNECTED = 1,
|
||||
CONNECTING = 2,
|
||||
}
|
||||
|
||||
export const RDP_DOCS_LINK =
|
||||
"https://docs.netbird.io/how-to/browser-client#rdp-connection";
|
||||
|
||||
export const useRemoteDesktop = (client: any) => {
|
||||
const [status, setStatus] = useState(RDPStatus.DISCONNECTED);
|
||||
const [config, setConfig] = useState<RDPConfig | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [pendingCertificate, setPendingCertificate] =
|
||||
useState<CertificatePromptInfo | null>(null);
|
||||
|
||||
const session = useRef<RDPConnection | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastConnectedConfigRef = useRef<RDPConfig | null>(null);
|
||||
const certificatePromiseRef = useRef<{
|
||||
resolve: (value: boolean) => void;
|
||||
reject: (reason?: any) => void;
|
||||
} | null>(null);
|
||||
|
||||
const { handleRDCleanPathResponse, acceptCertificate } =
|
||||
useRDPCertificateHandler();
|
||||
const certificateAccepted = useRef(false);
|
||||
|
||||
/**
|
||||
* Reset the RDP state, optionally preserving config and/or certificate state
|
||||
*/
|
||||
const resetState = useCallback(
|
||||
(
|
||||
options: {
|
||||
preserveConfig?: boolean;
|
||||
preserveCertificateState?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
session.current = null;
|
||||
setStatus(RDPStatus.DISCONNECTED);
|
||||
if (!options.preserveConfig) {
|
||||
setConfig(null);
|
||||
}
|
||||
setError("");
|
||||
if (!options.preserveCertificateState) {
|
||||
setPendingCertificate(null);
|
||||
certificatePromiseRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Set up the global RDPCertificateHandler to intercept certificate prompts
|
||||
*/
|
||||
const setupCertificateHandler = useCallback(() => {
|
||||
const originalHandler = (window as any).RDPCertificateHandler;
|
||||
|
||||
(window as any).RDPCertificateHandler = function () {
|
||||
this.handleRDCleanPathResponse = async (response: any) => {
|
||||
const result = await handleRDCleanPathResponse(response);
|
||||
|
||||
if (result.isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.needsUserConfirmation && result.promptInfo) {
|
||||
setPendingCertificate(result.promptInfo);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
certificatePromiseRef.current = { resolve, reject };
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (originalHandler?.prototype) {
|
||||
Object.getOwnPropertyNames(originalHandler.prototype).forEach(
|
||||
(name) => {
|
||||
if (
|
||||
name !== "constructor" &&
|
||||
name !== "handleRDCleanPathResponse"
|
||||
) {
|
||||
this[name] = originalHandler.prototype[name];
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return originalHandler;
|
||||
}, [handleRDCleanPathResponse]);
|
||||
|
||||
/**
|
||||
* Establish an RDP connection
|
||||
*/
|
||||
const connect = useCallback(
|
||||
async (rdpConfig: RDPConfig): Promise<RDPStatus> => {
|
||||
if (status === RDPStatus.CONNECTING) return status;
|
||||
|
||||
setStatus(RDPStatus.CONNECTING);
|
||||
setConfig(rdpConfig);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
if (!canvasRef.current) {
|
||||
throw new Error("Canvas not available for RDP rendering");
|
||||
}
|
||||
|
||||
if (!client?.ironRDPBridge || !client?.initializeIronRDP) {
|
||||
throw new Error("IronRDP components not available from client");
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = rdpConfig.width || 1024;
|
||||
canvas.height = rdpConfig.height || 768;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
}
|
||||
|
||||
const initialized = await client.initializeIronRDP();
|
||||
if (!initialized) {
|
||||
throw new Error("Failed to initialize IronRDP");
|
||||
}
|
||||
|
||||
const originalHandler = setupCertificateHandler();
|
||||
|
||||
try {
|
||||
const sessionId = await client.ironRDPBridge.connect(
|
||||
rdpConfig.hostname,
|
||||
rdpConfig.port,
|
||||
rdpConfig.username,
|
||||
rdpConfig.password,
|
||||
canvas,
|
||||
true,
|
||||
client.client,
|
||||
);
|
||||
|
||||
session.current = {
|
||||
id: sessionId,
|
||||
disconnect: (options = {}) => {
|
||||
try {
|
||||
if (client.ironRDPBridge && sessionId) {
|
||||
client.ironRDPBridge.disconnect(sessionId);
|
||||
}
|
||||
resetState(options);
|
||||
} catch (err) {
|
||||
resetState(options);
|
||||
}
|
||||
},
|
||||
};
|
||||
setStatus(RDPStatus.CONNECTED);
|
||||
lastConnectedConfigRef.current = rdpConfig;
|
||||
return RDPStatus.CONNECTED;
|
||||
} catch (err) {
|
||||
const ironError = err as IronError;
|
||||
const errorMessage = ironError.backtrace
|
||||
? ironError.backtrace()
|
||||
: "RDP connection failed";
|
||||
setError(errorMessage);
|
||||
resetState();
|
||||
throw Error(errorMessage);
|
||||
} finally {
|
||||
(window as any).RDPCertificateHandler = originalHandler;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "RDP connection failed";
|
||||
setError(errorMessage);
|
||||
resetState();
|
||||
throw Error(errorMessage);
|
||||
}
|
||||
},
|
||||
[client, status, setupCertificateHandler, resetState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept the pending certificate prompt
|
||||
*/
|
||||
const acceptCertificatePrompt = useCallback(
|
||||
(remember: boolean = false) => {
|
||||
if (!pendingCertificate || !certificatePromiseRef.current) return;
|
||||
|
||||
acceptCertificate(
|
||||
pendingCertificate.hostname,
|
||||
pendingCertificate.certificate,
|
||||
remember,
|
||||
);
|
||||
|
||||
certificatePromiseRef.current.resolve(true);
|
||||
setPendingCertificate(null);
|
||||
certificatePromiseRef.current = null;
|
||||
certificateAccepted.current = true;
|
||||
},
|
||||
[pendingCertificate, acceptCertificate],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reject the pending certificate prompt
|
||||
*/
|
||||
const rejectCertificatePrompt = useCallback(() => {
|
||||
if (!certificatePromiseRef.current) return;
|
||||
certificatePromiseRef.current.resolve(false);
|
||||
setPendingCertificate(null);
|
||||
certificatePromiseRef.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle window resize events - reconnect with new dimensions
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Only handle resize if we're connected and have a previous config
|
||||
if (status !== RDPStatus.CONNECTED || !lastConnectedConfigRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
|
||||
// Debounce resize handling for 1 second
|
||||
resizeTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
// Disconnect current session
|
||||
if (session.current) {
|
||||
session.current.disconnect({
|
||||
preserveConfig: true,
|
||||
preserveCertificateState: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Reconnect with new dimensions
|
||||
const newConfig = {
|
||||
...lastConnectedConfigRef.current!,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
await connect(newConfig);
|
||||
} finally {
|
||||
setIsResizing(false);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [status, connect]);
|
||||
|
||||
/**
|
||||
* Auto accept certificate if previously accepted (for reconnects)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (pendingCertificate && certificateAccepted.current) {
|
||||
acceptCertificatePrompt();
|
||||
}
|
||||
}, [acceptCertificatePrompt, pendingCertificate]);
|
||||
|
||||
return {
|
||||
connect,
|
||||
status,
|
||||
config,
|
||||
error,
|
||||
isResizing,
|
||||
session: session.current,
|
||||
canvasRef,
|
||||
|
||||
// Certificate handling
|
||||
pendingCertificate,
|
||||
acceptCertificatePrompt,
|
||||
rejectCertificatePrompt,
|
||||
};
|
||||
};
|
||||
206
src/modules/remote-access/rdp/websocket-proxy.ts
Normal file
206
src/modules/remote-access/rdp/websocket-proxy.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* WebSocket Proxy System for RDCleanPath connections
|
||||
* Provides WebSocket interface that routes through RDCleanPath proxy
|
||||
*/
|
||||
|
||||
import type { CertificateHandler, CertificateInfo, RDCleanPathResponse } from './rdp-certificate-handler';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
handleRDCleanPathWebSocket?: (ws: RDCleanPathProxyWebSocket, proxyID: string) => void;
|
||||
createRDCleanPathProxy?: (hostname: string, port: number) => Promise<string>;
|
||||
getRDCleanPathCertificate?: (proxyID: string) => Promise<RDCleanPathResponse | null>;
|
||||
RDCleanPathProxyWebSocket?: typeof RDCleanPathProxyWebSocket;
|
||||
sendToRDCleanPathProxy?: (proxyID: string, data: ArrayBuffer | Uint8Array | string) => void;
|
||||
closeRDCleanPathProxy?: (proxyID: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseWebSocketProxy extends EventTarget {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
readyState: number;
|
||||
readonly protocol: string = '';
|
||||
readonly extensions: string = '';
|
||||
readonly bufferedAmount: number = 0;
|
||||
readonly binaryType: BinaryType = 'blob';
|
||||
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
protected messageQueue: any[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.readyState = BaseWebSocketProxy.CONNECTING;
|
||||
}
|
||||
|
||||
get CONNECTING() { return BaseWebSocketProxy.CONNECTING; }
|
||||
get OPEN() { return BaseWebSocketProxy.OPEN; }
|
||||
get CLOSING() { return BaseWebSocketProxy.CLOSING; }
|
||||
get CLOSED() { return BaseWebSocketProxy.CLOSED; }
|
||||
|
||||
protected emitOpen(): void {
|
||||
this.readyState = BaseWebSocketProxy.OPEN;
|
||||
const event = new Event('open');
|
||||
this.onopen?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
protected emitError(error: any): void {
|
||||
const event = new Event('error');
|
||||
this.onerror?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
protected emitMessage(data: any): void {
|
||||
const event = new MessageEvent('message', { data });
|
||||
this.onmessage?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
protected emitClose(code = 1000, reason = ''): void {
|
||||
this.readyState = BaseWebSocketProxy.CLOSED;
|
||||
const event = new CloseEvent('close', { code, reason, wasClean: code === 1000 });
|
||||
this.onclose?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
abstract send(data: ArrayBuffer | Uint8Array | string | Blob | ArrayBufferView): void;
|
||||
abstract close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
export class RDCleanPathProxyWebSocket extends BaseWebSocketProxy {
|
||||
private proxyID: string;
|
||||
private certificateHandler: CertificateHandler | null;
|
||||
onGoMessage?: (data: Uint8Array) => void;
|
||||
onGoClose?: () => void;
|
||||
onCertificateRequest?: (certData: RDCleanPathResponse) => Promise<boolean>;
|
||||
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
const match = url.match(/rdcleanpath\.proxy\.local\/(.+)/);
|
||||
this.proxyID = match?.[1] || 'default';
|
||||
|
||||
if (window.RDPCertificateHandler) {
|
||||
this.certificateHandler = new window.RDPCertificateHandler();
|
||||
} else {
|
||||
this.certificateHandler = null;
|
||||
}
|
||||
this.onCertificateRequest = async (certData) => {
|
||||
return this.validateCertificate(certData);
|
||||
};
|
||||
void this._connect();
|
||||
}
|
||||
|
||||
private async _connect(): Promise<void> {
|
||||
try {
|
||||
const handler = (window as any)[`handleRDCleanPathWebSocket_${this.proxyID}`];
|
||||
if (!handler) {
|
||||
throw new Error(`RDCleanPath WebSocket handler not available for proxy ${this.proxyID}`);
|
||||
}
|
||||
handler(this);
|
||||
this.emitOpen();
|
||||
} catch (error) {
|
||||
console.error('RDCleanPath WebSocket connection failed:', error);
|
||||
this.emitError(error);
|
||||
this.emitClose(1006, error instanceof Error ? error.message : 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
protected _sendInternal(data: Uint8Array): void {
|
||||
if (this.onGoMessage) {
|
||||
this.onGoMessage(data);
|
||||
} else {
|
||||
console.warn('onGoMessage not set for proxy', this.proxyID);
|
||||
}
|
||||
}
|
||||
|
||||
send(data: ArrayBuffer | Uint8Array | string | Blob | ArrayBufferView): void {
|
||||
if (this.readyState === BaseWebSocketProxy.CONNECTING) {
|
||||
this.messageQueue.push(data);
|
||||
return;
|
||||
}
|
||||
if (this.readyState !== BaseWebSocketProxy.OPEN) {
|
||||
throw new Error('WebSocket is not open');
|
||||
}
|
||||
// Convert all data types to Uint8Array
|
||||
if (data instanceof Blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this._sendInternal(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.readAsArrayBuffer(data);
|
||||
} else if (typeof data === 'string') {
|
||||
const encoder = new TextEncoder();
|
||||
this._sendInternal(encoder.encode(data));
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
this._sendInternal(new Uint8Array(data));
|
||||
} else if ('buffer' in data && data.buffer instanceof ArrayBuffer) {
|
||||
const view = data as ArrayBufferView;
|
||||
this._sendInternal(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
||||
} else {
|
||||
this._sendInternal(data as Uint8Array);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCertificate(certData: RDCleanPathResponse): Promise<boolean> {
|
||||
if (!this.certificateHandler) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await this.certificateHandler.handleRDCleanPathResponse(certData);
|
||||
} catch (error) {
|
||||
console.error('Certificate validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Called from Go side to pass data to JavaScript
|
||||
receiveFromGo(data: ArrayBuffer): void {
|
||||
this.emitMessage(data);
|
||||
}
|
||||
|
||||
// Called from Go side to close the connection
|
||||
closeFromGo(code?: number, reason?: string): void {
|
||||
this.emitClose(code, reason);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = ''): void {
|
||||
if (this.readyState === BaseWebSocketProxy.CLOSING || this.readyState === BaseWebSocketProxy.CLOSED) {
|
||||
return;
|
||||
}
|
||||
this.readyState = BaseWebSocketProxy.CLOSING;
|
||||
if (this.onGoClose) {
|
||||
this.onGoClose();
|
||||
} else if (window.closeRDCleanPathProxy) {
|
||||
window.closeRDCleanPathProxy(this.proxyID);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.emitClose(code, reason);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function installWebSocketProxy(): void {
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = new Proxy(OriginalWebSocket, {
|
||||
construct(_target, args) {
|
||||
const url = args[0] as string;
|
||||
|
||||
if (url?.includes('rdcleanpath.proxy.local')) {
|
||||
window.RDCleanPathProxyWebSocket = RDCleanPathProxyWebSocket;
|
||||
return new RDCleanPathProxyWebSocket(url) as unknown as WebSocket;
|
||||
}
|
||||
|
||||
return new OriginalWebSocket(url, args[1]);
|
||||
}
|
||||
}) as typeof WebSocket;
|
||||
}
|
||||
76
src/modules/remote-access/ssh/SSHButton.tsx
Normal file
76
src/modules/remote-access/ssh/SSHButton.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import Button from "@components/Button";
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { CircleHelpIcon, TerminalIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
|
||||
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
isDropdown?: boolean;
|
||||
};
|
||||
|
||||
export const SSHButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const disabled =
|
||||
!peer.connected || !peer.ssh_enabled || !permission.peers.update;
|
||||
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
const os = getOperatingSystem(peer?.os);
|
||||
const isWindows = os === OperatingSystem.WINDOWS;
|
||||
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
|
||||
const isSSHSupported = !isWindows && !isMobile;
|
||||
|
||||
return (
|
||||
isSSHSupported && (
|
||||
<>
|
||||
{modal && (
|
||||
<SSHCredentialsModal
|
||||
open={modal}
|
||||
onOpenChange={setModal}
|
||||
peer={peer}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<SSHTooltip
|
||||
disabled={!disabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setModal(true)}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<TerminalIcon size={14} className={"shrink-0"} />
|
||||
SSH
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModal(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<TerminalIcon size={16} />
|
||||
SSH
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</SSHTooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
155
src/modules/remote-access/ssh/SSHCredentialsModal.tsx
Normal file
155
src/modules/remote-access/ssh/SSHCredentialsModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
TerminalIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import Separator from "@components/Separator";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Button from "@components/Button";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
const [username, setUsername] = useState(
|
||||
getOperatingSystem(peer.os) === OperatingSystem.WINDOWS
|
||||
? "Administrator"
|
||||
: "root",
|
||||
);
|
||||
|
||||
const [port, setPort] = useState(
|
||||
isNativeSSHSupported(peer.version) ? "22" : "44338",
|
||||
);
|
||||
|
||||
const userNameError = useMemo(() => {
|
||||
if (username?.length === 0) return "Username cannot be empty";
|
||||
}, [username]);
|
||||
|
||||
const portError = useMemo(() => {
|
||||
const portNumber = Number(port);
|
||||
const isValid =
|
||||
Number.isInteger(portNumber) && portNumber > 0 && portNumber <= 65535;
|
||||
if (!isValid) return "Port must be a number between 1 and 65535";
|
||||
}, [port]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
if (userNameError !== undefined) return true;
|
||||
return portError !== undefined;
|
||||
}, [userNameError, portError]);
|
||||
|
||||
const openSSHWindow = () => {
|
||||
const encodedUsername = encodeURIComponent(username.trim());
|
||||
const encodedPort = encodeURIComponent(port.trim());
|
||||
|
||||
window.open(
|
||||
`peer/ssh?id=${peer.id}&user=${encodedUsername}&port=${encodedPort}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=450,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass={"max-w-lg"}>
|
||||
<ModalHeader
|
||||
icon={<TerminalIcon className={"text-netbird"} size={18} />}
|
||||
title={peer.name}
|
||||
description={`Connect to ${peer.ip} via SSH`}
|
||||
color={"netbird"}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
<div className={""}>
|
||||
<Label>Username & Port</Label>
|
||||
<HelpText>
|
||||
The username and port you will use to connect to the remote host.
|
||||
</HelpText>
|
||||
<div className={"flex flex-col gap-2 w-full"}>
|
||||
<Input
|
||||
placeholder={"root"}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
customSuffix={`@${peer.ip}`}
|
||||
data-1p-ignore
|
||||
autoComplete={"off"}
|
||||
error={userNameError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
customPrefix={
|
||||
<User2 size={16} className={"text-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
maxWidthClass={""}
|
||||
placeholder={"22"}
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
type={"number"}
|
||||
error={portError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
customPrefix={
|
||||
<ChevronsLeftRightEllipsis
|
||||
size={16}
|
||||
className={"text-nb-gray-300"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={SSH_DOCS_LINK} target={"_blank"}>
|
||||
SSH
|
||||
<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"}
|
||||
disabled={hasAnyError}
|
||||
onClick={openSSHWindow}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
57
src/modules/remote-access/ssh/SSHTooltip.tsx
Normal file
57
src/modules/remote-access/ssh/SSHTooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
hasPermission?: boolean;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
export const SSHTooltip = ({
|
||||
disabled,
|
||||
children,
|
||||
hasPermission,
|
||||
side = "top",
|
||||
}: Props) => {
|
||||
return (
|
||||
<FullTooltip
|
||||
className={"w-full"}
|
||||
side={side}
|
||||
content={
|
||||
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
|
||||
{hasPermission ? (
|
||||
<>
|
||||
<div>
|
||||
This peer is either offline or SSH access is not enabled.
|
||||
</div>
|
||||
<div>
|
||||
Please enable SSH access for this peer in the dashboard and make
|
||||
sure SSH is allowed in the NetBird Client under{" "}
|
||||
<span className={"text-white"}>Settings → Allow SSH</span>.
|
||||
</div>
|
||||
<div>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/ssh"}
|
||||
target={"_blank"}
|
||||
>
|
||||
SSH <ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
You do not have permission to launch the SSH console. Please
|
||||
contact your administrator.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
172
src/modules/remote-access/ssh/Terminal.tsx
Normal file
172
src/modules/remote-access/ssh/Terminal.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const TERMINAL_OPTIONS = {
|
||||
theme: {
|
||||
background: "#181a1d",
|
||||
foreground: "#e4e7e9",
|
||||
cursor: "#e4e7e9",
|
||||
},
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
scrollback: 1000,
|
||||
allowTransparency: true,
|
||||
};
|
||||
|
||||
interface TerminalWithCore {
|
||||
_core?: { _isDisposed: boolean };
|
||||
dispose(): void;
|
||||
write(data: string | Uint8Array): void;
|
||||
writeln(data: string): void;
|
||||
onData(callback: (data: string) => void): void;
|
||||
onResize(callback: (event: { cols: number; rows: number }) => void): void;
|
||||
focus(): void;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface SSHTerminalWrapperProps {
|
||||
session: any;
|
||||
onResize?: (cols: number, rows: number) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Terminal = ({
|
||||
session,
|
||||
onResize,
|
||||
onClose,
|
||||
className = "",
|
||||
}: SSHTerminalWrapperProps) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstanceRef = useRef<{
|
||||
terminal: TerminalWithCore;
|
||||
fitAddon: any;
|
||||
} | null>(null);
|
||||
const handlersSetRef = useRef(false);
|
||||
|
||||
const fitTerminal = useCallback(() => {
|
||||
if (terminalInstanceRef.current?.fitAddon) {
|
||||
terminalInstanceRef.current.fitAddon.fit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initializeTerminal = useCallback(async () => {
|
||||
if (terminalInstanceRef.current || !terminalRef.current) return;
|
||||
|
||||
const { Terminal: XTerminal } = await import("@xterm/xterm");
|
||||
const { FitAddon } = await import("@xterm/addon-fit");
|
||||
|
||||
const terminal = new XTerminal(TERMINAL_OPTIONS);
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
if (!terminalRef.current) return;
|
||||
terminalRef.current.innerHTML = "";
|
||||
terminal.open(terminalRef.current);
|
||||
|
||||
// Set terminal focus behavior
|
||||
const terminalElement = terminalRef.current.querySelector(
|
||||
".xterm",
|
||||
) as HTMLElement;
|
||||
if (terminalElement) {
|
||||
terminalElement.setAttribute("tabindex", "0");
|
||||
terminalElement.addEventListener("click", () => terminal.focus());
|
||||
terminalElement.addEventListener("keydown", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
terminalInstanceRef.current = {
|
||||
terminal: terminal as TerminalWithCore,
|
||||
fitAddon,
|
||||
};
|
||||
|
||||
// Initial fit with delay to ensure proper sizing
|
||||
setTimeout(fitTerminal, 100);
|
||||
|
||||
return terminal as TerminalWithCore;
|
||||
}, [fitTerminal]);
|
||||
|
||||
const setupSSHHandlers = useCallback(async () => {
|
||||
if (!session || handlersSetRef.current) return;
|
||||
|
||||
const terminal = await initializeTerminal();
|
||||
if (!terminal) return;
|
||||
|
||||
handlersSetRef.current = true;
|
||||
|
||||
// Setup terminal event handlers
|
||||
terminal.onData((data: string) => session?.write?.(data));
|
||||
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
session?.resize?.(cols, rows);
|
||||
onResize?.(cols, rows);
|
||||
});
|
||||
|
||||
// Setup SSH event handlers
|
||||
session.ondata = (data: Uint8Array) => {
|
||||
if (!terminal._core?._isDisposed) {
|
||||
terminal.write(new Uint8Array(data));
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnClose = session.onclose;
|
||||
session.onclose = () => {
|
||||
if (!terminal._core?._isDisposed) {
|
||||
terminal.writeln("\r\n*** Connection closed ***");
|
||||
}
|
||||
handlersSetRef.current = false;
|
||||
originalOnClose?.();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// Final setup with proper sizing
|
||||
setTimeout(() => {
|
||||
if (
|
||||
terminalInstanceRef.current?.fitAddon &&
|
||||
!terminal._core?._isDisposed
|
||||
) {
|
||||
fitTerminal();
|
||||
session?.resize?.(terminal.cols, terminal.rows);
|
||||
terminal.focus();
|
||||
}
|
||||
}, 200);
|
||||
}, [session, initializeTerminal, onResize, onClose, fitTerminal]);
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => fitTerminal();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [fitTerminal]);
|
||||
|
||||
// Setup SSH handlers when session changes
|
||||
useEffect(() => {
|
||||
setupSSHHandlers().then();
|
||||
}, [setupSSHHandlers]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (terminalInstanceRef.current?.terminal) {
|
||||
const terminal = terminalInstanceRef.current.terminal;
|
||||
if (!terminal._core?._isDisposed) {
|
||||
terminal.dispose();
|
||||
}
|
||||
terminalInstanceRef.current = null;
|
||||
}
|
||||
handlersSetRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className={cn("w-full h-full flex flex-col m-0 p-0", className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user