Compare commits

...

5 Commits

Author SHA1 Message Date
Eduard Gert
9a401733b3 Fix toggle for p2p policies (#501)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-31 13:21:23 +01:00
Eduard Gert
07b6895380 Sync SSH & RDP changes (#495)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-16 14:44:26 +02:00
Eduard Gert
9e2e38764e Add control center (#494)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add control center

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

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

View File

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

View File

@@ -26,6 +26,7 @@ The dashboard makes it possible to:
- NextJS
- ReactJS
- Tailwind CSS
- [React Flow](https://reactflow.dev/) for the Control Center
- Auth0
- Nginx
- Docker

View File

@@ -13,5 +13,6 @@
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}

View File

@@ -7,6 +7,10 @@ server {
root /usr/share/nginx/html;
default_type application/wasm;
}
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;

View File

@@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH:-https://pkgs.netbird.io/wasm/client}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"

824
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "2.0.0",
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -30,6 +31,7 @@
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
@@ -37,6 +39,7 @@
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -44,8 +47,10 @@
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"elkjs": "^0.10.0",
"eslint": "^8",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
@@ -55,7 +60,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.481.0",
"lucide-react": "^0.539.0",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -201,6 +206,24 @@
"ms": "^2.1.1"
}
},
"node_modules/@dagrejs/dagre": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.5.tgz",
"integrity": "sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "2.2.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
"integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
"license": "MIT",
"engines": {
"node": ">17.0.0"
}
},
"node_modules/@emotion/is-prop-valid": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
@@ -2588,6 +2611,265 @@
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -2911,6 +3193,38 @@
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/@xyflow/react": {
"version": "12.8.6",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.70",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -3724,6 +4038,12 @@
"url": "https://joebell.co.uk"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
@@ -4209,6 +4529,416 @@
"node": "^16.0.0 || ^18.0.0 || >=20.0.0"
}
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -4305,6 +5035,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -4400,6 +5139,12 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.592.tgz",
"integrity": "sha512-D3NOkROIlF+d5ixnz7pAf3Lu/AuWpd6AYgI9O67GQXMXTcCP1gJQRotOq35eQy5Sb4hez33XH1YdTtILA7Udww=="
},
"node_modules/elkjs": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.2.tgz",
"integrity": "sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==",
"license": "EPL-2.0"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -5761,6 +6506,18 @@
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5860,6 +6617,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -6644,9 +7410,9 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.481.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.481.0.tgz",
"integrity": "sha512-NrvUDNFwgLIvHiwTEq9boa5Kiz1KdUT8RJ+wmNijwxdn9U737Fw42c43sRxJTMqhL+ySHpGRVCWpwiF+abrEjw==",
"version": "0.539.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -7836,6 +8602,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7858,6 +8630,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -7924,7 +8702,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/scheduler": {
@@ -8905,11 +9682,12 @@
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
@@ -9101,6 +9879,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -14,6 +14,7 @@
},
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -35,6 +36,7 @@
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
@@ -42,6 +44,7 @@
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -49,8 +52,10 @@
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"elkjs": "^0.10.0",
"eslint": "^8",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
@@ -60,7 +65,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.481.0",
"lucide-react": "^0.539.0",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",

View File

@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Control Center - ${globalMetaTitle}`,
};
export default BlankLayout;

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,14 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import { Callout } from "@components/Callout";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeersProvider from "@/contexts/PeersProvider";
@@ -59,6 +60,17 @@ export default function NetworkRoutes() {
</InlineLink>
in our documentation.
</Paragraph>
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
<span>
We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "}
<InlineLink href={"/networks"}>
Go to Networks
<ArrowUpRightIcon size={14} />
</InlineLink>
</span>
</Callout>
</div>
<RestrictedAccess hasAccess={permission.routes.read}>

View File

@@ -1,7 +1,15 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import Card from "@components/Card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
@@ -12,12 +20,14 @@ import { cn } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
MoreVertical,
PencilLineIcon,
ServerIcon,
ShieldCheckIcon,
ShieldXIcon,
Trash2,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
@@ -25,8 +35,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import NetworkModal from "@/modules/networks/NetworkModal";
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
@@ -77,35 +89,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center",
!network.description && "gap-2",
)}
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
{permission.networks.update && (
<button
className={
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
onClick={() => setNetworkModal(true)}
>
<PencilLineIcon size={18} />
</button>
)}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
onUpdated={() => {
mutate(`/networks/${network.id}`);
}}
network={network}
/>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkProvider network={network}>
<NetworkActions />
</NetworkProvider>
</div>
</div>
@@ -124,6 +125,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
);
}
function NetworkActions() {
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
const router = useRouter();
if (!network) return;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"secondary"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
onClick={() => openEditNetworkModal(network)}
disabled={!permission.networks.update}
>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
deleteNetwork(network).then(() => router.push("/networks"))
}
variant={"danger"}
disabled={!permission.networks.delete}
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
@@ -154,7 +205,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const policyCount = network.policies?.length ?? 0;
return (
<Card>
<Card className={"w-full lg:w-1/2"}>
<Card.List>
<Card.ListItem
tooltip={false}

View File

@@ -4,8 +4,6 @@ import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
@@ -38,12 +36,10 @@ import {
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TerminalSquare,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -66,8 +62,9 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -83,9 +80,8 @@ export default function PeerPage() {
const peerKey = useMemo(() => {
let id = peer?.id ?? "";
let ssh = peer?.ssh_enabled ? "1" : "0";
let expiration = peer?.login_expiration_enabled ? "1" : "0";
return `${id}-${ssh}-${expiration}`;
return `${id}-${expiration}`;
}, [peer]);
if (isRestricted) {
@@ -107,7 +103,7 @@ export default function PeerPage() {
);
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId}>
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peerKey} />
</PeerProvider>
) : (
@@ -141,8 +137,7 @@ function PeerOverview() {
const PeerGeneralInformation = () => {
const router = useRouter();
const { mutate } = useSWRConfig();
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
const [ssh, setSsh] = useState(peer.ssh_enabled);
const { peer, user, peerGroups, update } = usePeer();
const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false);
const [loginExpiration, setLoginExpiration] = useState(
@@ -161,7 +156,6 @@ const PeerGeneralInformation = () => {
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -174,7 +168,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
ssh,
loginExpiration,
inactivityExpiration,
});
@@ -190,7 +183,6 @@ const PeerGeneralInformation = () => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -314,51 +306,17 @@ const PeerGeneralInformation = () => {
)}
</div>
<FullTooltip
content={
<div
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
interactive={false}
className={"w-full block"}
disabled={permission.peers.update}
>
<FancyToggleSwitch
value={ssh}
disabled={!permission.peers.update}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
</FullTooltip>
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
{permission.groups.read && (
<div>
@@ -582,6 +540,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
/>
)}
{peer.created_at && (
<Card.ListItem
label={
<>
<CalendarDays size={16} />
Registered on
</>
}
value={
dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.created_at) +
")"
}
/>
)}
<Card.ListItem
label={
<>

View File

@@ -4,6 +4,7 @@ import { notify } from "@components/Notification";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
@@ -19,7 +20,6 @@ import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -31,7 +31,7 @@ export default function RDPPage() {
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
return (
<div className={"w-screen h-screen overflow-hidden"}>
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
) : (
@@ -55,7 +55,7 @@ function RDPSession({ peer }: Props) {
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, []);
}, [peer.ip, peer.name, connected, rdp]);
const sendErrorNotification = (title: string, message: string) => {
notify({
@@ -104,6 +104,7 @@ function RDPSession({ peer }: Props) {
port: credentials.port,
username: credentials.username,
password: credentials.password,
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
});

View File

@@ -2,6 +2,7 @@
import { PageNotFound } from "@components/ui/PageNotFound";
import useFetchApi, { ErrorResponse } from "@utils/api";
import { isNativeSSHSupported } from "@utils/version";
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
import React, { useEffect, useRef } from "react";
import type { Peer } from "@/interfaces/Peer";
@@ -86,7 +87,8 @@ function SSHTerminal({ username, port, peer }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
@@ -107,7 +109,8 @@ function SSHTerminal({ username, port, peer }: Props) {
if (connected.current) return;
connected.current = true;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,31 +34,34 @@ export default function PolicyDirection({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
const isNetworkResource =
!!destinationResource && destinationResource?.type !== "peer";
const topBadgeClass = useMemo(() => {
if (destinationResource) return "blueDark";
if (isNetworkResource) return "blueDark";
if (value === "bi") return "green";
if (value === "in") return "blueDark";
return "gray";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const topArrowClass = useMemo(() => {
if (destinationResource) return "fill-sky-500";
if (isNetworkResource) return "fill-sky-500";
if (value === "bi") return "fill-green-500";
if (value === "in") return "fill-sky-500";
return "fill-gray-500";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const bottomBadgeClass = useMemo(() => {
if (destinationResource) return "gray";
if (isNetworkResource) return "gray";
if (value === "bi") return "green";
return "gray";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const bottomArrowClass = useMemo(() => {
if (destinationResource) return "fill-gray-500";
if (isNetworkResource) return "fill-gray-500";
if (value === "bi") return "fill-green-500";
return "fill-gray-500";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
return (
<button

View File

@@ -1,18 +1,21 @@
import { notify } from "@components/Notification";
import SkeletonPeerDetail from "@components/skeletons/SkeletonPeerDetail";
import { useApiCall } from "@utils/api";
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { Group, GroupPeer } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { PeerSSHInstructions } from "@/modules/peer/PeerSSHInstructions";
type Props = {
children: React.ReactNode;
peer: Peer;
isPeerDetailPage?: boolean;
};
const PeerContext = React.createContext(
@@ -28,18 +31,25 @@ const PeerContext = React.createContext(
approval_required?: boolean;
ip?: string;
}) => Promise<Peer>;
openSSHDialog: () => Promise<boolean>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
deletePeer: () => void;
isLoading: boolean;
},
);
export default function PeerProvider({ children, peer }: Props) {
export default function PeerProvider({
children,
peer,
isPeerDetailPage = false,
}: Props) {
const user = usePeerUser(peer);
const { peerGroups, isLoading } = usePeerGroups(peer);
const peerRequest = useApiCall<Peer>("/peers", true);
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [sshInstructionsModal, setSSHInstructionsModal] = useState(false);
const deletePeer = async () => {
const choice = await confirm({
@@ -94,25 +104,20 @@ export default function PeerProvider({ children, peer }: Props) {
);
};
const openSSHDialog = async (): Promise<boolean> => {
return await confirm({
title: `Enable SSH Server for ${peer.name}?`,
description: (
<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 &rarr; Allow SSH</span>
</div>
</div>
),
confirmText: "Enable",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
const toggleSSH = async (enable: boolean) => {
if (!permission.peers.update) return;
notify({
title: peer.name,
description: enable
? "SSH Access successfully enabled"
: "SSH Access successfully disabled",
promise: update({ ssh: enable }).then(() => {
isPeerDetailPage ? mutate(`/peers/${peer.id}`) : mutate("/peers");
setSSHInstructionsModal(false);
}),
loadingMessage: enable
? "Enabling SSH Access..."
: "Disabling SSH Access...",
});
};
@@ -123,16 +128,25 @@ export default function PeerProvider({ children, peer }: Props) {
peerGroups,
user,
update,
openSSHDialog,
toggleSSH,
setSSHInstructionsModal,
deletePeer,
isLoading,
}}
>
{sshInstructionsModal && (
<PeerSSHInstructions
open={sshInstructionsModal}
onOpenChange={setSSHInstructionsModal}
onSuccess={() => toggleSSH(true)}
/>
)}
{children}
</PeerContext.Provider>
) : (
) : isPeerDetailPage ? (
<SkeletonPeerDetail />
);
) : null;
}
/**

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
"use client";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
@@ -67,6 +70,23 @@ export default function Navigation({
>
<div>
<SidebarItemGroup>
<SidebarItem
icon={<ControlCenterIcon size={16} />}
label={
<div className={"flex items-center gap-2"}>
Control Center
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[8px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</div>
}
href={"/control-center"}
visible={permission.policies.read}
/>
<SidebarItem
icon={<PeerIcon />}
label="Peers"

View File

@@ -289,7 +289,7 @@ export function AccessControlModalContent({
showRoutes={true}
showResources={false}
showPeers={true}
showResourceCounter={true}
showResourceCounter={false}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
values={sourceGroups}

View File

@@ -37,6 +37,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
if (rule.destinationResource) {
rule.destinations = null;
}
if (rule.sourceResource) {
rule.sources = null;
}
});
updatePolicy(

View File

@@ -16,7 +16,9 @@ export default function AccessControlDirectionCell({
}, [policy]);
const bidirectional = firstRule ? firstRule.bidirectional : false;
const isSingleResource = !!firstRule?.destinationResource;
const isSingleResource =
!!firstRule?.destinationResource &&
firstRule?.destinationResource?.type !== "peer";
return (
<div className={"flex h-full"}>

View File

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

View File

@@ -260,9 +260,9 @@ export const useAccessControl = ({
updatePolicy(
policy,
policyObj,
() => {
(p) => {
mutate("/policies");
onSuccess && onSuccess(policy);
onSuccess && onSuccess(p);
},
"The policy was successfully saved",
);
@@ -282,7 +282,10 @@ export const useAccessControl = ({
const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp";
const portDisabled = !hasPortSupport(protocol);
const isDestinationPeer = destinationResource?.type === "peer";
const destinationHasResources = useMemo(() => {
if (isDestinationPeer) return false;
if (destinationResource) return true;
return destinationGroups.some((group) => {
@@ -294,9 +297,10 @@ export const useAccessControl = ({
}
return false;
});
}, [destinationGroups, destinationResource]);
}, [destinationGroups, destinationResource, isDestinationPeer]);
const destinationOnlyResources = useMemo(() => {
if (isDestinationPeer) return false;
if (destinationResource) return true;
return (
@@ -318,13 +322,13 @@ export const useAccessControl = ({
return hasResources && !hasPeers;
})
);
}, [destinationGroups, destinationResource]);
}, [destinationGroups, destinationResource, isDestinationPeer]);
useEffect(() => {
if (destinationOnlyResources && direction !== "in") {
if (destinationOnlyResources && direction !== "in" && !isDestinationPeer) {
setDirection("in");
}
}, [destinationOnlyResources, direction, setDirection]);
}, [destinationOnlyResources, direction, setDirection, isDestinationPeer]);
return {
protocol,

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
import { SegmentedTabs } from "@components/SegmentedTabs";
import { FolderGit2, MonitorSmartphoneIcon, NetworkIcon } from "lucide-react";
import * as React from "react";
export enum FlowView {
NETWORKS = "networks",
GROUPS = "groups",
PEERS = "peers",
}
type Props = {
value?: FlowView;
onChange?: (value: FlowView) => void;
};
export const FlowSelector = ({ value, onChange }: Props) => {
return (
<SegmentedTabs value={value} onChange={(v) => onChange?.(v as FlowView)}>
<SegmentedTabs.List
className={
"border-b rounded-b-lg text-sm font-medium bg-nb-gray-930 p-1"
}
>
<SegmentedTabs.Trigger
value={FlowView.PEERS}
className={"text-xs px-3 py-1"}
>
<MonitorSmartphoneIcon size={12} />
Peers
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.GROUPS}
className={"text-xs px-3 py-1"}
>
<FolderGit2 size={12} />
Groups
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.NETWORKS}
className={"text-xs px-3 py-[0.45rem]"}
>
<NetworkIcon size={12} />
Networks
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
);
};

View File

@@ -0,0 +1,48 @@
import Button from "@components/Button";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useMemo } from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
type Props = {
network: Network;
};
export const NetworkRoutingPeerCount = ({ network }: Props) => {
const { data: routers, isLoading: isRoutersLoading } =
useFetchApi<NetworkRouter[]>("/networks/routers");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const routingPeerStatusColor = useMemo(() => {
if (!network) return "bg-nb-gray-500";
const routerCount = network.routers?.length || 0;
if (routerCount === 0) return "bg-nb-gray-500";
if (routerCount === 1) return "bg-yellow-400";
if (routerCount > 1) return "bg-green-400";
return "bg-nb-gray-500";
}, [network]);
const networkRouters = useMemo(() => {
if (!network || !peers) return [];
const routerIds = network?.routers?.map((r) => r) || [];
return routers?.filter((r) => routerIds.includes(r.id)) || [];
}, [network, peers, routers]);
return (
<Button
variant={"secondary"}
size={"xs"}
className={"!bg-nb-gray-930 !text-nb-gray-300 cursor-default"}
>
<CircleIcon
size={8}
className={cn("shrink-0 block", routingPeerStatusColor)}
/>
{network.routers?.length || 0} Routing Peer(s)
</Button>
);
};

View File

@@ -0,0 +1,125 @@
import { Edge, useInternalNode } from "@xyflow/react";
import React from "react";
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
type AnimatedLineProps = Edge<
{
label?: string;
color?: string;
},
"animated-line"
>;
function AnimatedLine({ id, source, target, data }: AnimatedLineProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) return null;
const { sx, sy, tx, ty } = getEdgeParams(sourceNode, targetNode);
const labelX = (sx + tx) / 2;
const labelY = (sy + ty) / 2;
let angle = Math.atan2(ty - sy, tx - sx) * (180 / Math.PI);
if (angle < -90 || angle > 90) {
angle += 180;
}
const label = data?.label || "";
const hasLabel = label?.length > 0;
const fontSize = 12;
const paddingX = hasLabel ? 2 : 0;
const paddingY = hasLabel ? 2 : 0;
const gapWidth = hasLabel ? 4 : 0;
const labelTextWidth = label.length * 7;
const labelWidth = gapWidth + labelTextWidth + paddingX * 2;
const labelHeight = fontSize + paddingY * 2;
const dx = tx - sx;
const dy = ty - sy;
const length = Math.sqrt(dx * dx + dy * dy);
const gap = labelWidth / 2;
const nx = dx / length;
const ny = dy / length;
const preLabelX = labelX - nx * gap;
const preLabelY = labelY - ny * gap;
const postLabelX = labelX + nx * gap;
const postLabelY = labelY + ny * gap;
const color = data?.color || "#0e9f6e";
return (
<>
<line
x1={sx}
y1={sy}
x2={preLabelX}
y2={preLabelY}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
<line
x1={postLabelX}
y1={postLabelY}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
{label && hasLabel && (
<foreignObject
x={labelX - labelWidth / 2}
y={labelY - labelHeight / 2}
width={labelWidth}
height={labelHeight}
style={{ overflow: "visible" }}
>
<div
style={{
width: labelWidth,
height: labelHeight,
fontSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: `${paddingY}px ${paddingX}px`,
transform: `rotate(${angle}deg)`,
transformOrigin: "center center",
boxSizing: "border-box",
background: "none",
}}
className={
"flex items-center justify-center gap-1 select-none pointer-events-none z-10 text-green-50"
}
>
<div className={"whitespace-nowrap"}>{label}</div>
</div>
</foreignObject>
)}
</>
);
}
export default AnimatedLine;

View File

@@ -0,0 +1,70 @@
import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
import React from "react";
export function BidirectionalEdges({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) {
const [forwardPath] = getSmoothStepPath({
sourceX: sourceX - 5,
sourceY: sourceY - 5,
sourcePosition,
targetX: targetX + 15,
targetY: targetY - 5,
targetPosition,
});
const [backwardPath] = getSmoothStepPath({
sourceX: targetX + 5,
sourceY: targetY + 5,
sourcePosition: targetPosition,
targetX: sourceX - 15,
targetY: sourceY + 5,
targetPosition: sourcePosition,
});
return (
<>
<BaseEdge
id={`${id}-forward`}
path={forwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
<BaseEdge
id={`${id}-backward`}
path={backwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
</>
);
}

View File

@@ -0,0 +1,92 @@
import {
BaseEdge,
type EdgeProps,
getSimpleBezierPath,
getSmoothStepPath,
getStraightPath,
} from "@xyflow/react";
import React from "react";
type Props = {
data: {
enabled: boolean;
type: "smoothstep" | "straight" | "bezier";
};
} & EdgeProps;
export function DirectionIn({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}: Props) {
const { enabled, type = "straight" } = data;
const getPath = () => {
switch (type) {
case "straight":
return getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
case "bezier":
return getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
case "smoothstep":
return getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
default:
return getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
}
};
const [edgePath] = getPath();
return (
<BaseEdge
id={id}
path={edgePath}
style={{
opacity: enabled ? 1 : 0.6,
strokeWidth: 2,
stroke: enabled ? "#0e9f6e" : "#787878",
strokeDasharray: "5, 5",
}}
>
{enabled && (
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
)}
</BaseEdge>
);
}

View File

@@ -0,0 +1,53 @@
import {
BaseEdge,
EdgeProps,
getBezierPath,
useInternalNode,
} from "@xyflow/react";
import React from "react";
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) {
return null;
}
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode,
targetNode,
);
const [edgePath] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetPosition: targetPos,
targetX: tx,
targetY: ty,
});
return (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
);
}
export default FloatingEdge;

View File

@@ -0,0 +1,45 @@
import { BaseEdge, type EdgeProps, getSimpleBezierPath } from "@xyflow/react";
import React from "react";
import { useSourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type Props = {
data: {
enabled: boolean;
};
} & EdgeProps;
export function SimpleConnection({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
source,
}: Props) {
const [edgePath] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const enabled = useSourceGroupEnabled(source);
return (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 1.5,
stroke: "#595959",
strokeDasharray: "0, 0",
opacity: enabled ? 1 : 0.6,
}}
></BaseEdge>
);
}

View File

@@ -0,0 +1,111 @@
import TruncatedText from "@components/ui/TruncatedText";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { NetworkResource } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
};
export const DeviceCard = ({
device,
resource,
className,
}: DeviceCardProps) => {
if (!device && !resource) return;
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-850 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device && <PeerOSIcon os={device.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
<span
className={
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
}
>
{device?.ip || resource?.address}
</span>
</div>
</div>
);
};
const PeerOSIcon = ({ os }: { os: string }) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};
const ResourceIcon = ({
type,
size = 15,
}: {
type: "domain" | "host" | "subnet";
size?: number;
}) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

@@ -0,0 +1,80 @@
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { useMemo } from "react";
import { Group } from "@/interfaces/Group";
type GroupNodeProps = Node<
{
group: Group;
enabled: boolean;
hoverable?: boolean;
onClick?: (g: Group) => void;
},
"groupNode"
>;
export const GroupNode = ({ data, id }: GroupNodeProps) => {
const { enabled = true, group, hoverable = true, onClick } = data;
const countLabel = useMemo(() => {
const peerCount = group?.peers_count || 0;
const resourceCount = group?.resources_count || 0;
if (resourceCount === 0) {
return `${peerCount} Peer(s)`;
}
if (peerCount === 0) {
return `${resourceCount} Resource(s)`;
}
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
}, [group?.peers_count, group?.resources_count]);
return (
<div
className={cn(
"cc-group-node bg-nb-gray-940 border border-nb-gray-800 rounded-lg overflow-hidden transition-all",
!enabled && "opacity-60",
hoverable && "hover:bg-nb-gray-930 cursor-pointer",
)}
onClick={() => onClick?.(group)}
>
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={14} />
</div>
<div>
<div className={" text-nb-gray-200 font-normal whitespace-nowrap"}>
{group.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap text-xs"}>
{countLabel}
</div>
</div>
</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import { NetworkIcon } from "lucide-react";
import * as React from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { Network, NetworkResource } from "@/interfaces/Network";
type NetworkNodeType = {
network: Network;
};
type NetworkNodeProps = Node<NetworkNodeType, "networkNode">;
export const NetworkNode = ({ data }: NetworkNodeProps) => {
const { data: networkResources, isLoading: isLoadingResources } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const n = data.network as Network;
const resourceIds = n?.resources || [];
const routingPeers = n?.routers || [];
const resources =
networkResources?.filter((r) => resourceIds.includes(r?.id || "")) || [];
return (
<div
className={cn(
"bg-nb-gray-940 border border-nb-gray-800 rounded-2xl overflow-hidden group hover:bg-nb-gray-935 transition-all cursor-pointer",
)}
>
<div
className={cn(
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-6 pr-6 py-3.5 font-normal bg-nb-gray-935 border-b border-nb-gray-800 group-hover:bg-nb-gray-930 transition-all",
resources?.length === 0 && "border-b-0",
)}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div>
<div
className={
" text-nb-gray-100 font-medium whitespace-nowrap flex items-center gap-2"
}
>
<NetworkIcon size={12} />
{n?.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap mt-0.5"}>
{resources?.length || 0} Resources
</div>
</div>
</div>
<div className={"flex items-center gap-2 text-xs"}>
<CircleIcon
size={8}
className={cn(
"shrink-0 block",
routingPeers?.length === 0 && "bg-nb-gray-500",
routingPeers?.length === 1 && "bg-yellow-400",
routingPeers?.length > 1 && "bg-green-400",
)}
/>
{routingPeers?.length || 0} Routing Peer(s)
</div>
</div>
{resources && resources.length > 0 && (
<div className={"p-2 flex flex-col gap-4 relative"}>
<div className={"grid grid-cols-2 relative z-0"}>
{resources?.slice(0, 6).map((r) => {
return <DeviceCard resource={r} key={r.id} />;
})}
</div>
<div
className={cn(
"absolute w-full h-full bg-gradient-to-b from-transparent via-nb-gray-940/20 to-nb-gray-940 z-10 left-0 top-0 pointer-events-none",
resources?.length > 6 ? "opacity-100" : "opacity-0",
)}
></div>
</div>
)}
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
opacity: 0,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type PeerNodeProps = Node<
{
peer: Peer;
enabled?: boolean;
},
"peerNode"
>;
export const PeerNode = ({ data, id }: PeerNodeProps) => {
const { peer, enabled } = data;
const isEnabled = useAnySourceGroupEnabled(id);
return (
<div
className={
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
device={peer}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,66 @@
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { getPolicyProtocolAndPortText } from "@/modules/control-center/utils/helpers";
import { Policy } from "@/interfaces/Policy";
type PolicyNode = Node<
{
policy: Policy;
},
"policyNode"
>;
export const PolicyNode = ({ data }: PolicyNode) => {
const rule = data.policy.rules?.[0];
const label = getPolicyProtocolAndPortText(data.policy);
const isActive = rule?.enabled;
return (
<div
className={cn(
"relative bg-nb-gray-940 hover:bg-nb-gray-930 cursor-pointer border border-nb-gray-800 rounded-full flex justify-between overflow-hidden",
!isActive && "opacity-60",
)}
>
<div className={"flex items-center justify-center"}>
<div
className={cn(
"h-2 w-2 rounded-full ml-3 mr-2",
isActive ? "bg-green-400" : "bg-nb-gray-400",
)}
></div>
</div>
<div className={"pt-2.5 pb-[0.6rem] pr-3 flex gap-4 leading-none"}>
<div
className={
" text-nb-gray-200 font-normal whitespace-nowrap text-[0.8rem] flex items-center justify-center w-full"
}
>
<div className={"truncate max-w-[200px]"}>{rule?.name}</div>
</div>
</div>
<div
className={
"border-l border-nb-gray-800 flex items-center text-nb-gray-300 text-[0.65rem] pl-2 pr-3 font-mono"
}
>
<div>{label === "" ? "All" : label}</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type ResourceNode = Node<
{
resource: NetworkResource;
enabled?: boolean;
},
"resourceNode"
>;
export const ResourceNode = ({ data, id }: ResourceNode) => {
const { enabled, resource } = data;
const isEnabled = useAnySourceGroupEnabled(id);
return (
<div
className={
"cursor-pointer border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
resource={resource}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,135 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import useFetchApi from "@utils/api";
import { Handle, type Node, Position } from "@xyflow/react";
import { sortBy } from "lodash";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { Group } from "@/interfaces/Group";
type NodeProps = Node<
{
currentGroup: string;
onChange: (id: string) => void;
},
"selectGroupNode"
>;
export const SelectGroupNode = ({ data, id }: NodeProps) => {
const { data: groups, isLoading: isGroupsLoading } =
useFetchApi<Group[]>("/groups");
const groupOptions: SelectOption[] = sortBy(
groups?.map(
(g) =>
({
value: g.id,
label: g.name,
icon: () => (
<GroupBadgeIcon id={g?.id} issued={g?.issued} size={14} />
),
}) as SelectOption,
) || [],
"label",
"asc",
);
const group = groups?.find((g) => g.id === data.currentGroup);
const countLabel = useMemo(() => {
const peerCount = group?.peers_count || 0;
const resourceCount = group?.resources_count || 0;
if (resourceCount === 0) {
return `${peerCount} Peer(s)`;
}
if (peerCount === 0) {
return `${resourceCount} Resource(s)`;
}
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
}, [group]);
return (
<div
className={
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<SelectDropdown
variant={"secondary"}
value={data.currentGroup}
onChange={data.onChange}
options={groupOptions}
showSearch={true}
searchPlaceholder={"Search groups..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}
maxHeight={300}
>
<div className={"flex items-center justify-between gap-8 pr-3"}>
{group && (
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon
id={group?.id}
issued={group?.issued}
size={14}
/>
</div>
<div>
<div
className={
" text-nb-gray-200 font-normal whitespace-nowrap text-left"
}
>
{group.name}
</div>
<div
className={
"text-nb-gray-400 whitespace-nowrap text-xs text-left"
}
>
{countLabel}
</div>
</div>
</div>
</div>
)}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import { sortBy } from "lodash";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type PeerNodeProps = Node<
{
currentPeer: string;
onPeerChange: (peerId: string) => void;
},
"selectPeerNode"
>;
export const SelectPeerNode = ({ data, id }: PeerNodeProps) => {
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const peerSelectOptions: SelectOption[] = sortBy(
peers?.map(
(p) =>
({
value: p.id,
label: p.name,
icon: () => {
const os = p.os as unknown as OperatingSystem;
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
os === OperatingSystem.WINDOWS && "p-[2.5px]",
os === OperatingSystem.APPLE && "p-[2.7px]",
os === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={p.os} />
</div>
);
},
}) as SelectOption,
) || [],
"label",
"asc",
);
const peer = peers?.find((p) => p.id === data.currentPeer);
return (
<div
className={
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<SelectDropdown
variant={"secondary"}
value={data.currentPeer}
onChange={data.onPeerChange}
options={peerSelectOptions}
showSearch={true}
searchPlaceholder={"Search peers..."}
popoverWidth={280}
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
size={"xs"}
maxHeight={300}
>
<div className={"flex items-center justify-between gap-8 pr-3"}>
{peer && <DeviceCard device={peer} />}
<ChevronsUpDown size={18} className={"shrink-0"} />
</div>
</SelectDropdown>
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
height: 20,
width: "1px",
border: "none",
backgroundColor: "#3f444b",
borderRadius: "0px 4px 4px 0px",
right: -2,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { InternalNode, Node, Position } from "@xyflow/react";
type IntersectionPoint = {
x: number;
y: number;
};
function getNodeIntersection(
intersectionNode: InternalNode<Node>,
targetNode: InternalNode<Node>,
) {
const { width: intersectionNodeWidth, height: intersectionNodeHeight } =
intersectionNode.measured;
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
const targetPosition = targetNode.internals.positionAbsolute;
const measuredTargetWidth = targetNode.measured.width || 0;
const measuredTargetHeight = targetNode.measured.height || 0;
const w = (intersectionNodeWidth || 0) / 2;
const h = (intersectionNodeHeight || 0) / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + measuredTargetWidth / 2;
const y1 = targetPosition.y + measuredTargetHeight / 2;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;
return { x, y };
}
function getEdgePosition(
node: InternalNode<Node>,
intersectionPoint: IntersectionPoint,
) {
const n = { ...node.internals.positionAbsolute, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
const measuredWidth = n.measured.width || 0;
const measuredHeight = n.measured.height || 0;
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + measuredWidth - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= n.y + measuredHeight - 1) {
return Position.Bottom;
}
return Position.Top;
}
export function getEdgeParams(
source: InternalNode<Node>,
target: InternalNode<Node>,
) {
const sourceIntersectionPoint: IntersectionPoint = getNodeIntersection(
source,
target,
);
const targetIntersectionPoint: IntersectionPoint = getNodeIntersection(
target,
source,
);
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
const targetPos = getEdgePosition(target, targetIntersectionPoint);
return {
sx: sourceIntersectionPoint.x,
sy: sourceIntersectionPoint.y,
tx: targetIntersectionPoint.x,
ty: targetIntersectionPoint.y,
sourcePos,
targetPos,
};
}

View File

@@ -0,0 +1,13 @@
import AnimatedLine from "@/modules/control-center/edges/AnimatedLine";
import { BidirectionalEdges } from "@/modules/control-center/edges/BidirectionalEdges";
import { DirectionIn } from "@/modules/control-center/edges/DirectionIn";
import FloatingEdge from "@/modules/control-center/edges/FloatingEdge";
import { SimpleConnection } from "@/modules/control-center/edges/SimpleConnection";
export const EDGE_TYPES = {
in: DirectionIn,
bi: BidirectionalEdges,
floating: FloatingEdge,
"floating-straight": AnimatedLine,
simple: SimpleConnection,
};

View File

@@ -0,0 +1,145 @@
import { useReactFlow } from "@xyflow/react";
import { orderBy } from "lodash";
import { Group } from "@/interfaces/Group";
import { Network } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
export const getDestinationGroupsFromPolicy = (policy: Policy) => {
const rule = policy.rules?.[0];
if (!rule) return [];
const destinations = rule.destinations as Group[];
if (!destinations) return [];
return destinations;
};
export const getSourceGroupsFromPolicy = (policy: Policy) => {
const rule = policy.rules?.[0];
if (!rule) return [];
const sources = rule.sources as Group[];
if (!sources) return [];
return sources;
};
export const getNetworksFromPolicy = (networks: Network[], policy: Policy) => {
const policyId = policy.id;
if (!policyId) return [];
return networks.filter((network) => {
return network.policies?.some((p) => p === policyId);
});
};
export const getPeersFromGroup = (group: Group, peers: Peer[]) => {
return peers.filter((peer) => {
const groupIds = peer.groups?.map((g) => g.id) || [];
return groupIds.includes(group.id);
});
};
export const getPolicyProtocolAndPortText = (
policy: Policy,
maxPorts?: number,
) => {
const rule = policy.rules?.[0];
if (!rule) return "";
let p = rule.protocol;
if (p === "all") {
return "";
} else if (p === "icmp") {
return "ICMP";
} else {
const ports = getPolicyPortsText(policy);
if (!ports || ports.length === 0) {
return p.toUpperCase();
}
if (ports.length > (maxPorts ?? 3)) {
const firstFour = ports.slice(0, 4);
return `${p.toUpperCase()}:${firstFour.join(",")}, ...`;
}
return `${p.toUpperCase()}:${ports.join(",")}`;
}
};
export const getPolicyPortsText = (policy: Policy) => {
const rule = policy.rules?.[0];
if (!rule) return undefined;
const ports = rule.ports || [];
const portRanges = rule.port_ranges || [];
if (ports.length === 0 && portRanges.length === 0) {
return undefined;
}
const portStrings = ports.map((port) => String(port));
const rangeStrings = portRanges.map((range) => {
if (range.start === range.end) return String(range.start);
return `${range.start}-${range.end}`;
});
return orderBy(
[...portStrings, ...rangeStrings],
[(x) => Number(x.split("-")[0])],
["asc"],
);
};
export const getResourcePolicyByGroups = (
groups: Group[],
policies: Policy[],
): Policy[] => {
const groupIds = groups.map((group) => group.id);
return policies.filter((policy) => {
const rule = policy.rules?.[0];
if (!rule) return false;
const destinations = rule.destinations as Group[];
return destinations?.some((d) => groupIds.includes(d.id));
});
};
export function useSourceGroupEnabled(sourceId: string) {
const { getNode } = useReactFlow();
const node = getNode(sourceId);
return node?.data?.enabled ?? false;
}
export function useAnySourceGroupEnabled(sourceId: string) {
const { getNodes, getEdges } = useReactFlow();
const nodes = getNodes();
const edges = getEdges();
const incomingEdges = edges.filter((e) => e.target === sourceId);
const sourceNodes = incomingEdges
.map((edge) => nodes.find((n) => n.id === edge.source))
.filter(Boolean);
const sourceEnabledStates = sourceNodes.map((n) => n?.data?.enabled);
return sourceEnabledStates.some(Boolean);
}
export function getFirstGroup(groups?: Group[], policies?: Policy[]) {
const sortedGroups = orderBy(groups, "peers_count", "desc");
const groupsWithoutAll = sortedGroups?.filter((g) => g.name !== "All");
const groupsWithPolicies = orderBy(
groupsWithoutAll?.filter((g) => {
return policies?.some((p) => {
const sources = getSourceGroupsFromPolicy(p);
return sources?.some((source) => source.id === g.id);
});
}),
"peers_count",
"desc",
);
if (groupsWithPolicies && groupsWithPolicies?.length > 0) {
return groupsWithPolicies[0];
}
if (groupsWithoutAll && groupsWithoutAll?.length > 0) {
return groupsWithoutAll[0];
}
return sortedGroups?.[0];
}

View File

@@ -0,0 +1,245 @@
import { Edge, Node } from "@xyflow/react";
import * as d3 from "d3";
interface SimulationNode extends Node {
x: number;
y: number;
vx?: number;
vy?: number;
}
export const DEFAULT_MAX_ZOOM = 0.8;
export const DEFAULT_MIN_ZOOM = 0.2;
export const applyD3ForceLayout = (nodes: Node[], edges: Edge[]) => {
const simulationNodes: SimulationNode[] = nodes.map((node) => ({
...node,
x: node.position?.x || 0,
y: node.position?.y || 0,
}));
const simulationLinks = edges.map((edge) => ({
...edge,
source: edge.source,
target: edge.target,
}));
// Apply minimal D3 simulation for final positioning with reduced link distance
const simulation = d3
.forceSimulation(simulationNodes)
.force(
"link",
d3
.forceLink(simulationLinks)
.id((d: any) => d.id)
.distance(60) // Reduced distance to minimize crossings
.strength(0.05), // Reduced strength to maintain radial structure
)
.force("collision", d3.forceCollide().radius(300));
// Run simulation for fewer iterations to preserve radial structure
for (let i = 0; i < 1000; i++) {
simulation.tick();
}
const updatedNodes: Node[] = simulationNodes.map((node) => ({
...node,
position: {
x: node.x,
y: node.y,
},
}));
const updatedEdges: Edge[] = edges.map((edge) => {
const sourceNode = simulationNodes.find((n) => n.id === edge.source);
const targetNode = simulationNodes.find((n) => n.id === edge.target);
return {
...edge,
data: {
...edge.data,
points:
sourceNode && targetNode
? [
{ x: sourceNode.x, y: sourceNode.y },
{ x: targetNode.x, y: targetNode.y },
]
: undefined,
},
};
});
simulation.stop();
return { updatedNodes, updatedEdges };
};
export const applyD3HierarchicalLayout = (
nodes: Node[],
edges: Edge[],
width = 280,
spacing = 100,
view?: string,
options?: {
policy?: { width: number; spacing: number };
destinationGroup?: { width: number; spacing: number };
peersAndResources?: { width: number; spacing: number };
},
) => {
const simulationNodes: SimulationNode[] = nodes.map((node) => ({
...node,
x: node.position?.x || 0,
y: node.position?.y || 0,
}));
const columnWidth = width;
const nodeSpacing = spacing;
const startX = 0;
const centerY = 0;
const groupNodes = simulationNodes.filter((n) => n.type === "groupNode");
const sourceGroupNodes = simulationNodes.filter(
(n) => n.type === "sourceGroupNode",
);
const destinationGroupNodes = simulationNodes.filter(
(n) => n.type === "destinationGroupNode",
);
const policyNodes = simulationNodes.filter((n) => n.type === "policyNode");
const networkNodes = simulationNodes.filter((n) => n.type === "networkNode");
const resourceNodes = simulationNodes.filter(
(n) => n.type === "resourceNode",
);
const peerNodes = simulationNodes.filter((n) => n.type === "peerNode");
const expandedGroupPeers = simulationNodes.filter(
(n) => n.type === "expandedGroupPeer",
);
let networkAndResourceNodes = [...networkNodes, ...resourceNodes];
if (view === "group") {
networkAndResourceNodes = [...networkAndResourceNodes, ...peerNodes];
}
if (view === "peer") {
networkAndResourceNodes = [
...networkAndResourceNodes,
...expandedGroupPeers,
];
}
// Peers
if (peerNodes.length > 0 && view !== "group") {
centerNodesVertically(
peerNodes,
startX + (view === "group" ? columnWidth * 4 : 0),
nodeSpacing,
centerY,
);
}
// Groups or Source Groups
centerNodesVertically(groupNodes, startX, nodeSpacing, centerY);
centerNodesVertically(
sourceGroupNodes,
startX + columnWidth,
nodeSpacing,
centerY,
);
// Policies
centerNodesVertically(
policyNodes,
startX + (options?.policy?.width ?? columnWidth),
options?.policy?.spacing ?? nodeSpacing,
centerY + 14,
);
// Destination Groups
centerNodesVertically(
destinationGroupNodes,
startX + (options?.destinationGroup?.width ?? columnWidth),
options?.destinationGroup?.spacing ?? nodeSpacing,
centerY,
);
// Networks
centerNodesVertically(
networkAndResourceNodes,
startX + (options?.peersAndResources?.width ?? columnWidth),
options?.peersAndResources?.spacing ?? nodeSpacing,
centerY + 5,
);
const simulation = d3
.forceSimulation(simulationNodes)
.force("charge", d3.forceManyBody().strength(0))
.force("collision", d3.forceCollide().radius(0))
.alphaDecay(0.05)
.velocityDecay(0.7);
simulation.force("position", (alpha) => {
simulationNodes.forEach((node) => {
let targetX = node.x;
let targetY = node.y;
const dx = targetX - node.x;
const dy = targetY - node.y;
node.vx = (node.vx || 0) + dx * alpha * 0.1;
node.vy = (node.vy || 0) + dy * alpha * 0.1;
});
});
for (let i = 0; i < 100; i++) {
simulation.tick();
}
const updatedNodes: Node[] = simulationNodes.map((node) => ({
...node,
position: {
x: node.x,
y: node.y,
},
}));
const updatedEdges: Edge[] = edges.map((edge) => {
const sourceNode = simulationNodes.find((n) => n.id === edge.source);
const targetNode = simulationNodes.find((n) => n.id === edge.target);
return {
...edge,
data: {
...edge.data,
points:
sourceNode && targetNode
? [
{ x: sourceNode.x, y: sourceNode.y },
{ x: targetNode.x, y: targetNode.y },
]
: undefined,
},
};
});
simulation.stop();
return { updatedNodes, updatedEdges };
};
const centerNodesVertically = (
nodesList: SimulationNode[],
x: number,
nodeSpacing: number,
centerY: number,
enable = true,
) => {
if (nodesList.length === 0) return;
const totalHeight = (nodesList.length - 1) * nodeSpacing;
const startY = centerY - totalHeight / 2;
nodesList.forEach((node, index) => {
node.x = x;
node.y = (enable ? startY : 0) + index * nodeSpacing;
});
};

View File

@@ -0,0 +1,20 @@
import { GroupNode } from "@/modules/control-center/nodes/GroupNode";
import { NetworkNode } from "@/modules/control-center/nodes/NetworkNode";
import { PeerNode } from "@/modules/control-center/nodes/PeerNode";
import { PolicyNode } from "@/modules/control-center/nodes/PolicyNode";
import { ResourceNode } from "@/modules/control-center/nodes/ResourceNode";
import { SelectGroupNode } from "@/modules/control-center/nodes/SelectGroupNode";
import { SelectPeerNode } from "@/modules/control-center/nodes/SelectPeerNode";
export const NODE_TYPES = {
groupNode: GroupNode,
sourceGroupNode: GroupNode,
destinationGroupNode: GroupNode,
networkNode: NetworkNode,
resourceNode: ResourceNode,
policyNode: PolicyNode,
peerNode: PeerNode,
expandedGroupPeer: PeerNode,
selectPeerNode: SelectPeerNode,
selectGroupNode: SelectGroupNode,
};

View File

@@ -72,6 +72,8 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
};
const removePeerFromGroup = async (g: Group) => {
if (g.name === "All") return Promise.resolve(g);
const newPeerGroups = g.peers?.filter((p) => {
const groupPeer = p as GroupPeer;
return groupPeer.id !== peer?.id;

View File

@@ -29,7 +29,7 @@ const NetworksContext = React.createContext(
resource?: NetworkResource,
) => void;
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
deleteNetwork: (network: Network) => void;
deleteNetwork: (network: Network) => Promise<void>;
deleteResource: (network: Network, resource: NetworkResource) => void;
deleteRouter: (network: Network, router: NetworkRouter) => void;
network?: Network;
@@ -124,15 +124,19 @@ export const NetworkProvider = ({ children, network }: Props) => {
if (!choice) return;
const promise = deleteCall({}, `/${network.id}`).then(() => {
mutate("/networks");
mutate("/groups");
});
notify({
title: network.name,
description: "Network deleted successfully.",
loadingMessage: "Deleting network...",
promise: deleteCall({}, `/${network.id}`).then(() => {
mutate("/networks");
mutate("/groups");
}),
promise,
});
return promise;
};
const deleteResource = async (
@@ -250,8 +254,9 @@ export const NetworkProvider = ({ children, network }: Props) => {
mutate("/networks");
await askForResource(network);
}}
onUpdated={() => {
onUpdated={(n) => {
mutate("/networks");
mutate(`/networks/${n.id}`);
}}
/>
<Modal

View File

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

View File

@@ -13,7 +13,7 @@ export default function NetworkNameCell({ network }: Readonly<Props>) {
);
return (
<div className={"flex gap-4 items-center min-w-[300px] max-w-[300px]"}>
<div className={"flex gap-4 items-center min-w-[300px] max-w-[450px]"}>
<NetworkInformationSquare
name={network.name}
active={isActive}

View File

@@ -0,0 +1,111 @@
import Button from "@components/Button";
import Code from "@components/Code";
import InlineLink from "@components/InlineLink";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import Steps from "@components/Steps";
import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, TerminalSquare } from "lucide-react";
import * as React from "react";
import sshImage from "@/assets/ssh/ssh-client.png";
type Props = {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
};
export const PeerSSHInstructions = ({
open,
onOpenChange,
onSuccess,
}: Props) => {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass={cn("relative", "max-w-2xl")}
showClose={true}
>
<ModalHeader
icon={<TerminalSquare size={16} className={"text-netbird"} />}
title={"Enable SSH Access"}
description={
"Allow remote SSH access to this machine from other connected network participants. NetBird's embedded SSH server is running on port 44338."
}
color={"netbird"}
/>
<Separator />
<div className={"px-8 py-3 flex flex-col gap-0 z-0"}>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
If you are using NetBird via CLI, you can enable SSH by running
</p>
<Code codeToCopy={"netbird down"}>
<Code.Line>{`netbird down # if NetBird is already running`}</Code.Line>
</Code>
<Code>
<Code.Line>{`netbird up --allow-server-ssh`}</Code.Line>
</Code>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
If you are using NetBird via the Desktop Client, click on the
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "}
<Mark>Allow SSH</Mark> <br />
</p>
<Lightbox image={sshImage} />
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Once the NetBird SSH server is allowed on the client, <br />
click <Mark>Confirm & Enable</Mark> below to finish the setup.
</p>
</Steps.Step>
</Steps>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/ssh"}
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"}
onClick={onSuccess}
data-cy={"create-setup-key"}
>
Confirm & Enable
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,47 @@
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import { LockIcon, TerminalSquare } from "lucide-react";
import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
export const PeerSSHToggle = () => {
const { permission } = usePermissions();
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
return (
<>
<FullTooltip
content={
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
interactive={false}
className={"w-full block"}
disabled={permission.peers.update}
>
<FancyToggleSwitch
value={peer.ssh_enabled}
disabled={!permission.peers.update}
onChange={(enable) =>
enable ? setSSHInstructionsModal(true) : toggleSSH(false)
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
</FullTooltip>
</>
);
};

View File

@@ -24,7 +24,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
export default function PeerActionCell() {
const { peer, deletePeer, update, openSSHDialog } = usePeer();
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
usePeer();
const router = useRouter();
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
@@ -48,21 +49,6 @@ export default function PeerActionCell() {
});
};
const toggleSSH = async () => {
const text = peer.ssh_enabled ? "disabled" : "enabled";
notify({
title: `SSH Server is ${text}`,
description: `The SSH Server for the peer ${peer.name} was successfully ${text}.`,
promise: update({
ssh: !peer.ssh_enabled,
}).then(() => {
mutate("/peers");
mutate("/groups");
}),
loadingMessage: "Updating SSH access...",
});
};
return (
<div className={"flex justify-end pr-4 gap-3"}>
<DropdownMenu modal={false}>
@@ -118,10 +104,8 @@ export default function PeerActionCell() {
<DropdownMenuItem
onClick={() =>
peer.ssh_enabled
? toggleSSH()
: openSSHDialog().then((enable) =>
enable ? toggleSSH() : null,
)
? toggleSSH(false)
: setSSHInstructionsModal(true)
}
disabled={!permission.peers.update}
>

View File

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

View File

@@ -6,12 +6,12 @@ import {
import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
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();

View File

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

View File

@@ -1,3 +1,4 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import {
Tooltip,
@@ -14,7 +15,6 @@ import { useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import FullTooltip from "@components/FullTooltip";
type Props = {
version: string;

View File

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

View File

@@ -1,8 +1,13 @@
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Peer } from "@/interfaces/Peer";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import { IconLoader2 } from "@tabler/icons-react";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
@@ -10,18 +15,13 @@ import {
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 * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { Peer } from "@/interfaces/Peer";
import {
RDP_DOCS_LINK,
RDPCredentials,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import { IconLoader2 } from "@tabler/icons-react";
type Props = {
open: boolean;
@@ -61,9 +61,31 @@ export const RDPCredentialsModal = ({
const handleConnect = useCallback(() => {
if (hasAnyError || !onConnect) return;
let parsedUsername = username;
let parsedDomain = "";
// Parse DOMAIN\username format
if (username.includes("\\")) {
const parts = username.split("\\");
if (parts.length === 2) {
parsedDomain = parts[0];
parsedUsername = parts[1];
}
}
// Parse username@domain format
else if (username.includes("@")) {
const parts = username.split("@");
if (parts.length === 2) {
parsedUsername = parts[0];
parsedDomain = parts[1];
}
}
onConnect({
username,
username: parsedUsername,
password,
domain: parsedDomain,
port: Number(port),
});
}, [hasAnyError, onConnect, username, password, port]);
@@ -111,11 +133,12 @@ export const RDPCredentialsModal = ({
<Label>Username & Password</Label>
<HelpText>
Enter the credentials required to authenticate with the remote
host.
host. For domain accounts, use DOMAIN\username or username@domain
format.
</HelpText>
<div className={"flex flex-col gap-2 w-full"}>
<Input
placeholder={"Administrator"}
placeholder={"Administrator or DOMAIN\\username"}
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -29,7 +29,6 @@ export interface RDPSession {
shutdown(): void;
sendInput(input: unknown): void;
onClipboardPaste?(content: ClipboardData): Promise<void>;
inputHandler?: IronRDPInputHandler;
}
interface TerminationInfo {
reason(): string;
@@ -57,11 +56,6 @@ interface RDPConfig {
declare global {
interface Window {
IronRDPBridge: IronRDPWASMBridge;
IronRDPInputHandler?: new (
ironrdp: IronRDPModule,
session: RDPSession,
canvas: HTMLCanvasElement,
) => IronRDPInputHandler;
initializeIronRDP: () => Promise<boolean>;
onIronRDPReady?: () => void;
createRDCleanPathProxy?: (
@@ -70,9 +64,6 @@ declare global {
) => Promise<string>;
}
}
interface IronRDPInputHandler {
destroy(): void;
}
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
@@ -115,7 +106,8 @@ export class IronRDPWASMBridge {
port: number,
username: string,
password: string,
canvas: HTMLCanvasElement,
domain?: string,
canvas?: HTMLCanvasElement,
enableClipboard = true,
netbirdClient?: {
createRDPProxy: (hostname: string, port: string) => Promise<string>;
@@ -132,9 +124,9 @@ export class IronRDPWASMBridge {
const config: RDPConfig = {
username,
password,
domain: "",
width: canvas.width || 1024,
height: canvas.height || 768,
domain: domain || "",
width: canvas?.width || 1024,
height: canvas?.height || 768,
enable_tls: true,
enable_credssp: true,
enable_nla: true,
@@ -177,9 +169,6 @@ export class IronRDPWASMBridge {
builder.authToken("");
const session = await builder.connect();
this.sessions.set(sessionId, session);
if (canvas) {
this.attachInputHandler(session, canvas);
}
if (enableClipboard) {
this.startClipboardEventListeners();
}
@@ -203,24 +192,7 @@ export class IronRDPWASMBridge {
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()
@@ -234,9 +206,6 @@ export class IronRDPWASMBridge {
});
}
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
@@ -244,12 +213,82 @@ export class IronRDPWASMBridge {
this.stopClipboardEventListeners();
}
}
private formatWSAError(wsaCode: number): string {
const wsaDescriptions: Record<number, string> = {
10004: "interrupted system call",
10009: "bad file descriptor",
10013: "permission denied",
10014: "bad address",
10022: "invalid argument",
10024: "too many open files",
10035: "resource temporarily unavailable",
10036: "operation now in progress",
10037: "operation already in progress",
10038: "socket operation on nonsocket",
10039: "destination address required",
10040: "message too long",
10041: "protocol wrong type for socket",
10042: "bad protocol option",
10043: "protocol not supported",
10044: "socket type not supported",
10045: "operation not supported",
10046: "protocol family not supported",
10047: "address family not supported by protocol family",
10048: "address already in use",
10049: "cannot assign requested address",
10050: "network is down",
10051: "network is unreachable",
10052: "network dropped connection on reset",
10053: "software caused connection abort",
10054: "connection reset by peer",
10055: "no buffer space available",
10056: "socket is already connected",
10057: "socket is not connected",
10058: "cannot send after socket shutdown",
10060: "connection timed out",
10061: "connection refused",
10064: "host is down",
10065: "no route to host",
10067: "too many processes",
10091: "network subsystem is unavailable",
10092: "Winsock version not supported",
10093: "successful WSAStartup not yet performed",
10101: "graceful shutdown in progress",
10109: "class type not found",
11001: "host not found",
11002: "nonauthoritative host not found",
11003: "this is a nonrecoverable error",
11004: "valid name, no data record of requested type",
};
return wsaDescriptions[wsaCode] || "unknown error";
}
private formatRDCleanPathError(backtraceMsg: string): string {
const wsaMatch = backtraceMsg.match(/WSA last error = (\d+)/);
if (wsaMatch) {
const wsaCode = parseInt(wsaMatch[1], 10);
const description = this.formatWSAError(wsaCode);
return `Connection failed: ${description} (WSA ${wsaCode})`;
}
const httpMatch = backtraceMsg.match(/HTTP status code = (\d+)/);
if (httpMatch) {
return `Connection failed: HTTP ${httpMatch[1]}`;
}
return backtraceMsg;
}
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());
const backtraceMsg = ironError.backtrace();
const formattedMsg = this.formatRDCleanPathError(backtraceMsg);
console.error("IronRDP error:", formattedMsg);
console.debug("IronRDP backtrace:", backtraceMsg);
}
if (ironError.kind) {
const errorKind = ironError.kind();
@@ -269,13 +308,13 @@ export class IronRDPWASMBridge {
console.error("Could not extract IronError details:", e);
}
}
getSession(sessionId: string): RDPSession | null {
return this.sessions.get(sessionId) || null;
}
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();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useIronRDPInputHandler } from "./useIronRDPInputHandler";
import {
CertificatePromptInfo,
useRDPCertificateHandler,
@@ -14,6 +15,7 @@ interface RDPConfig {
port: number;
username: string;
password: string;
domain?: string;
width?: number;
height?: number;
}
@@ -21,6 +23,7 @@ interface RDPConfig {
export interface RDPCredentials {
username: string;
password: string;
domain?: string;
port: number;
}
@@ -38,7 +41,7 @@ export enum RDPStatus {
CONNECTING = 2,
}
export const RDP_DOCS_LINK = "https://docs.netbird.io/";
export const RDP_DOCS_LINK = "https://docs.netbird.io/how-to/browser-client";
export const useRemoteDesktop = (client: any) => {
const [status, setStatus] = useState(RDPStatus.DISCONNECTED);
@@ -58,10 +61,20 @@ export const useRemoteDesktop = (client: any) => {
reject: (reason?: any) => void;
} | null>(null);
const [rdpSession, setRdpSession] = useState<any>(null);
const [ironrdpModule, setIronrdpModule] = useState<any>(null);
const { handleRDCleanPathResponse, acceptCertificate } =
useRDPCertificateHandler();
const certificateAccepted = useRef(false);
const { isActive, focusCanvas } = useIronRDPInputHandler({
ironrdp: ironrdpModule,
session: rdpSession,
canvas: canvasRef.current,
isConnected: status === RDPStatus.CONNECTED,
});
/**
* Reset the RDP state, optionally preserving config and/or certificate state
*/
@@ -74,6 +87,9 @@ export const useRemoteDesktop = (client: any) => {
) => {
session.current = null;
setStatus(RDPStatus.DISCONNECTED);
setRdpSession(null);
setIronrdpModule(null);
if (!options.preserveConfig) {
setConfig(null);
}
@@ -171,11 +187,17 @@ export const useRemoteDesktop = (client: any) => {
rdpConfig.port,
rdpConfig.username,
rdpConfig.password,
rdpConfig.domain,
canvas,
true,
client.client,
);
// Store the ironrdp module and session for the input handler hook
setIronrdpModule((client.ironRDPBridge as any).ironrdp || null);
const actualSession = client.ironRDPBridge.getSession(sessionId);
setRdpSession(actualSession);
session.current = {
id: sessionId,
disconnect: (options = {}) => {
@@ -191,6 +213,7 @@ export const useRemoteDesktop = (client: any) => {
};
setStatus(RDPStatus.CONNECTED);
lastConnectedConfigRef.current = rdpConfig;
canvasRef?.current?.focus();
return RDPStatus.CONNECTED;
} catch (err) {
const ironError = err as IronError;
@@ -231,6 +254,7 @@ export const useRemoteDesktop = (client: any) => {
setPendingCertificate(null);
certificatePromiseRef.current = null;
certificateAccepted.current = true;
canvasRef?.current?.focus();
},
[pendingCertificate, acceptCertificate],
);
@@ -286,6 +310,7 @@ export const useRemoteDesktop = (client: any) => {
await connect(newConfig);
} finally {
setIsResizing(false);
canvasRef?.current?.focus();
}
}, 1000);
};
@@ -317,6 +342,10 @@ export const useRemoteDesktop = (client: any) => {
session: session.current,
canvasRef,
// Input handler
inputHandlerActive: isActive,
focusCanvas,
// Certificate handling
pendingCertificate,
acceptCertificatePrompt,

View File

@@ -1,14 +1,14 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, TerminalIcon } 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 { 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;
@@ -41,7 +41,8 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
)}
<div>
<SSHTooltip
disabled={!disabled}
isOnline={peer.connected}
isSSHEnabled={peer.ssh_enabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>

View File

@@ -1,55 +1,99 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
import { ArrowUpRightIcon } from "lucide-react";
import * as React from "react";
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
import { useState } from "react";
import { usePeer } from "@/contexts/PeerProvider";
type Props = {
disabled?: boolean;
children?: React.ReactNode;
hasPermission?: boolean;
hasPermission: boolean;
isOnline?: boolean;
isSSHEnabled?: boolean;
side?: "top" | "right" | "bottom" | "left";
};
export const SSHTooltip = ({
disabled,
children,
hasPermission,
isOnline,
isSSHEnabled,
side = "top",
}: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipContent = () => {
if (!hasPermission) {
return <NoPermissionText />;
}
if (!isSSHEnabled) {
return <SSHDisabledText setShowTooltip={setShowTooltip} />;
}
if (!isOnline) {
return <IsOfflineText />;
}
return null;
};
return (
<FullTooltip
customOpen={showTooltip}
customOnOpenChange={setShowTooltip}
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 &rarr; Allow SSH</span>.
</div>
<div>
Learn more about{" "}
<InlineLink href={SSH_DOCS_LINK} 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}
content={tooltipContent()}
disabled={isOnline && isSSHEnabled && hasPermission}
>
{children}
</FullTooltip>
);
};
const NoPermissionText = () => {
return (
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
<div>
You do not have permission to launch the SSH console. Please contact
your administrator.
</div>
</div>
);
};
const IsOfflineText = () => {
return (
<div className={"max-w-[200px] text-xs"}>
<div>Connecting via SSH is only available when the peer is online.</div>
</div>
);
};
const SSHDisabledText = ({
setShowTooltip,
}: {
setShowTooltip: (show: boolean) => void;
}) => {
const { setSSHInstructionsModal } = usePeer();
return (
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
<div>
SSH Access is currently disabled for this peer. Please enable SSH access
for this peer and make sure SSH is allowed in the NetBird Client.
</div>
<div>
<InlineLink
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowTooltip(false);
setSSHInstructionsModal(true);
}}
href={"#"}
target={"_blank"}
>
Enable SSH Access <ArrowUpRightIcon size={12} />
</InlineLink>
</div>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { useOidcAccessToken } from "@axa-fr/react-oidc";
import { useCallback, useRef, useState } from "react";
interface SSHConfig {
@@ -20,13 +21,15 @@ export enum SSHStatus {
CONNECTING = 2,
}
export const SSH_DOCS_LINK = "https://docs.netbird.io/";
export const SSH_DOCS_LINK =
"https://docs.netbird.io/how-to/browser-client#ssh-connection";
export const useSSH = (client: any) => {
const [status, setStatus] = useState(SSHStatus.DISCONNECTED);
const [config, setConfig] = useState<SSHConfig | null>(null);
const session = useRef<SSHConnection | null>(null);
const [error, setError] = useState("");
const { accessToken } = useOidcAccessToken();
const connect = useCallback(
async (config: SSHConfig): Promise<SSHStatus> => {

View File

@@ -1,19 +1,18 @@
import { useApiCall } from "@utils/api";
import loadConfig from "@utils/config";
import { getBrowserInfo } from "@utils/helpers";
import { generateKeypair } from "@utils/wireguard";
import { trim } from "lodash";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { IronRDPInputHandler } from "@/modules/remote-access/rdp/ironrdp-input-handler";
import { IronRDPWASMBridge } from "@/modules/remote-access/rdp/ironrdp-wasm-bridge";
import { RDPCertificateHandler } from "@/modules/remote-access/rdp/rdp-certificate-handler";
import { installWebSocketProxy } from "@/modules/remote-access/rdp/websocket-proxy";
import { useApiCall } from "@utils/api";
import { generateKeypair } from "@utils/wireguard";
import { getBrowserInfo } from "@utils/helpers";
import { trim } from "lodash";
const config = loadConfig();
const WASM_CONFIG = {
SCRIPT_PATH: "/wasm_exec.js",
WASM_PATH: "/netbird.wasm",
WASM_PATH: config.wasmPath,
INIT_TIMEOUT: 10000,
RETRY_DELAY: 100,
} as const;
@@ -73,9 +72,8 @@ export const useNetBirdClient = () => {
const rdpComponents = useRef<{
bridge: IronRDPWASMBridge | null;
inputHandler: typeof IronRDPInputHandler | null;
certificateHandler: typeof RDPCertificateHandler | null;
}>({ bridge: null, inputHandler: null, certificateHandler: null });
}>({ bridge: null, certificateHandler: null });
const loadWASMRuntime = useCallback((): Promise<void> => {
if (document.querySelector(`script[src="${WASM_CONFIG.SCRIPT_PATH}"]`)) {
@@ -117,7 +115,6 @@ export const useNetBirdClient = () => {
installWebSocketProxy();
rdpComponents.current = {
bridge: new IronRDPWASMBridge(),
inputHandler: IronRDPInputHandler,
certificateHandler: RDPCertificateHandler,
};
}, []);
@@ -184,6 +181,7 @@ export const useNetBirdClient = () => {
});
await netBirdClient.current.start();
(window as any).netbird = netBirdClient.current;
netBirdStore.setState({ status: NetBirdStatus.CONNECTED });
return true;
} catch (error) {
@@ -206,11 +204,27 @@ export const useNetBirdClient = () => {
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
await netBirdClient.current.stop();
netBirdClient.current = null;
delete (window as any).netbird;
return Promise.resolve();
}, []);
const detectSSHServerType = useCallback(
async (host: string, port: number): Promise<boolean> => {
if (!netBirdClient.current?.detectSSHServerType) {
throw new Error("NetBird client not ready");
}
return netBirdClient.current.detectSSHServerType(host, port);
},
[],
);
const createSSHConnection = useCallback(
async (host: string, port: number, username: string): Promise<any> => {
async (
host: string,
port: number,
username: string,
jwtToken?: string,
): Promise<any> => {
if (!netBirdClient.current?.createSSHConnection) {
throw new Error("Go client not ready");
}
@@ -268,7 +282,7 @@ export const useNetBirdClient = () => {
{
name,
wg_pub_key: keyPairs.publicKey,
rules: rules ?? ["tcp/22", "tcp/3389", "tcp/44338"],
rules: rules ?? ["tcp/22022", "tcp/3389", "tcp/44338"],
},
`/${peerId}/temporary-access`,
);
@@ -289,15 +303,15 @@ export const useNetBirdClient = () => {
status,
wasmStatus,
error,
client: netBirdClient.current, // Expose the raw NetBird client
client: netBirdClient.current,
ironRDPBridge: rdpComponents.current.bridge,
ironRDPInputHandler: rdpComponents.current.inputHandler,
rdpCertificateHandler: rdpComponents.current.certificateHandler,
initialize,
initializeIronRDP,
connect,
connectTemporary,
disconnect,
detectSSHServerType,
createSSHConnection,
makeRequest,
proxyRequest,

View File

@@ -79,23 +79,13 @@ export default function MacOSTab({
</div>
<div className={"flex gap-4 mt-1 flex-wrap"}>
<Link
href={"https://pkgs.netbird.io/macos/amd64"}
href={"https://pkgs.netbird.io/macos/universal"}
passHref
target={"_blank"}
>
<Button variant={"primary"}>
<DownloadIcon size={14} />
Download for Intel
</Button>
</Link>
<Link
href={"https://pkgs.netbird.io/macos/arm64"}
passHref
target={"_blank"}
>
<Button variant={"outline"}>
<DownloadIcon size={14} />
Download for Apple Silicon
Download NetBird
</Button>
</Link>
</div>

View File

@@ -1,11 +1,12 @@
import Button from "@components/Button";
import Code from "@components/Code";
import { SelectDropdown } from "@components/select/SelectDropdown";
import Steps from "@components/Steps";
import TabsContentPadding, { TabsContent } from "@components/Tabs";
import { getNetBirdUpCommand, GRPC_API_ORIGIN } from "@utils/netbird";
import { DownloadIcon, PackageOpenIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import React, { useState } from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import {
HostnameParameter,
@@ -24,6 +25,9 @@ export default function WindowsTab({
showSetupKeyInfo,
hostname,
}: Readonly<Props>) {
const [windowsUrl, setWindowsUrl] = useState(
"https://pkgs.netbird.io/windows/x64",
);
return (
<TabsContent value={String(OperatingSystem.WINDOWS)}>
<TabsContentPadding>
@@ -35,10 +39,35 @@ export default function WindowsTab({
<Steps.Step step={1}>
<p>Download and run Windows Installer</p>
<div className={"flex gap-4 mt-1"}>
<SelectDropdown
value={windowsUrl}
className={"w-[170px]"}
onChange={setWindowsUrl}
placeholder={"Select architecture"}
options={[
{
label: "64-Bit",
value: "https://pkgs.netbird.io/windows/x64",
},
{
label: "ARM64",
value: "https://pkgs.netbird.io/windows/arm64",
},
{
label: "64-Bit (MSI)",
value: "https://pkgs.netbird.io/windows/msi/x64",
},
{
label: "ARM64 (MSI)",
value: "https://pkgs.netbird.io/windows/msi/arm64",
},
]}
/>
<Link
href={"https://pkgs.netbird.io/windows/x64"}
href={windowsUrl}
passHref
target={"_blank"}
rel="noopener noreferrer"
>
<Button variant={"primary"}>
<DownloadIcon size={14} />

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ interface Config {
hotjarTrackID?: number;
googleAnalyticsID?: string;
googleTagManagerID?: string;
wasmPath: string;
}
/**
@@ -66,6 +67,7 @@ const loadConfig = (): Config => {
hotjarTrackID: configJson?.hotjarTrackID || undefined,
googleAnalyticsID: configJson?.googleAnalyticsID || undefined,
googleTagManagerID: configJson?.googleTagManagerID || undefined,
wasmPath: configJson.wasmPath ?? "https://pkgs.netbird.io/wasm/client",
} as Config;
};

View File

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