Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 | ||
|
|
aff2365ef7 | ||
|
|
bad057d415 | ||
|
|
4d846e2c94 | ||
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 | ||
|
|
8eebec78b4 | ||
|
|
3e01a6dafd | ||
|
|
1555b94043 | ||
|
|
6c62127d42 | ||
|
|
b71d0fde89 | ||
|
|
84c239ce30 | ||
|
|
ba66201c64 | ||
|
|
c6341e000f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,6 +37,8 @@ next-env.d.ts
|
||||
|
||||
# config
|
||||
.local-config.json
|
||||
.test-config.json
|
||||
cypress.env.json
|
||||
.configs/.local-config.zitadel.json
|
||||
.configs/.staging-config.json
|
||||
.configs/.temp-config.json
|
||||
|
||||
19
README.md
19
README.md
@@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird)
|
||||
|
||||
The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird).
|
||||
The dashboard makes it possible to:
|
||||
|
||||
- track the status of your peers
|
||||
- remove peers
|
||||
- manage Setup Keys (to authenticate new peers)
|
||||
@@ -17,10 +18,10 @@ The dashboard makes it possible to:
|
||||
- define access controls
|
||||
|
||||
## Some Screenshots
|
||||
|
||||
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
|
||||
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
|
||||
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- NextJS
|
||||
@@ -33,8 +34,9 @@ The dashboard makes it possible to:
|
||||
- Let's Encrypt
|
||||
|
||||
## How to run
|
||||
|
||||
Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to
|
||||
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
|
||||
use Auth0 service that covers all our needs (user management, social login, JWT for the management API).
|
||||
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
|
||||
1. Install [Docker](https://docs.docker.com/get-docker/)
|
||||
@@ -43,9 +45,9 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
|
||||
`AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE`
|
||||
|
||||
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins"
|
||||
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react) up until "Configure Allowed Web Origins"
|
||||
|
||||
4. NetBird UI Dashboard uses NetBirds Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
|
||||
4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
|
||||
5. Run docker container without SSL (Let's Encrypt):
|
||||
|
||||
```shell
|
||||
@@ -54,9 +56,10 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
|
||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
6. Run docker container with SSL (Let's Encrypt):
|
||||
|
||||
```shell
|
||||
@@ -68,7 +71,7 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
|
||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
@@ -84,11 +87,11 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
|
||||
You can start editing by modifying the code inside `src/..`
|
||||
The page auto-updates as you edit the file.
|
||||
|
||||
## How to migrate from old dashboard (v1)
|
||||
## How to migrate from old dashboard (v1)
|
||||
|
||||
The new dashboard comes with a new docker image `netbirdio/dashboard:main`.
|
||||
To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below.
|
||||
|
||||
1. Stop the dashboard container `docker compose down dashboard`
|
||||
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
|
||||
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
|
||||
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{
|
||||
"tag": "New",
|
||||
"text": "Custom DNS Zones for Private Network Resolution",
|
||||
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
|
||||
"linkText": "Read Release Article",
|
||||
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
|
||||
"link": "https://docs.netbird.io/manage/reverse-proxy",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
"closeable": true,
|
||||
|
||||
177
package-lock.json
generated
177
package-lock.json
generated
@@ -59,15 +59,14 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^16.1.6",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-hotjar": "^6.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -75,6 +74,7 @@
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -90,6 +90,9 @@
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -165,6 +168,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1209,9 +1213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1225,9 +1229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1241,9 +1245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1257,9 +1261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1273,9 +1277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1289,9 +1293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1305,9 +1309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1321,9 +1325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1337,9 +1341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3027,6 +3031,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3036,6 +3041,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -3094,6 +3100,7 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -3248,13 +3255,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3600,7 +3607,8 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.0",
|
||||
@@ -3639,6 +3647,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3656,9 +3665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -4056,6 +4065,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4684,6 +4694,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -5212,6 +5223,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5409,6 +5421,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -5755,9 +5768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
@@ -6010,15 +6023,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.18",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -6711,6 +6715,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -6918,9 +6923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.539.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
|
||||
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
|
||||
"version": "0.566.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.566.0.tgz",
|
||||
"integrity": "sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -6968,9 +6973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -7062,14 +7067,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.1.6",
|
||||
"@next/env": "16.1.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
@@ -7081,14 +7086,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.1.6",
|
||||
"@next/swc-darwin-x64": "16.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
||||
"@next/swc-linux-x64-musl": "16.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
||||
"@next/swc-darwin-arm64": "16.1.7",
|
||||
"@next/swc-darwin-x64": "16.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||
"@next/swc-linux-x64-musl": "16.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -7465,6 +7470,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7672,6 +7678,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7712,6 +7719,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7725,23 +7733,6 @@
|
||||
"integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hotjar": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hotjar/-/react-hotjar-6.3.1.tgz",
|
||||
@@ -8336,6 +8327,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -8613,6 +8614,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -8779,6 +8781,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8944,6 +8947,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9271,6 +9275,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -67,15 +67,14 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^16.1.6",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-hotjar": "^6.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -83,6 +82,7 @@
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -6,5 +6,5 @@ import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function DNS() {
|
||||
router.push("/dns/nameservers");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
80
src/app/(dashboard)/events/proxy/page.tsx
Normal file
80
src/app/(dashboard)/events/proxy/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import dayjs from "dayjs";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import React, { useMemo } from "react";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import ReverseProxyEventsTable from "@/modules/reverse-proxy/events/ReverseProxyEventsTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
|
||||
export default function ProxyEventsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const defaultFilters = useMemo(
|
||||
() => ({
|
||||
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
sort_by: "timestamp",
|
||||
sort_order: "desc",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="p-default py-6">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
label="Activity"
|
||||
disabled
|
||||
icon={<ActivityIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href="/events/proxy"
|
||||
label="Proxy Events"
|
||||
icon={<ReverseProxyIcon size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<h1 ref={headingRef}>Proxy Events</h1>
|
||||
|
||||
<Paragraph>
|
||||
View access logs for your reverse proxy services, including allowed
|
||||
and denied requests.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target="_blank">
|
||||
Proxy Events <ExternalLinkIcon size={12} />
|
||||
</InlineLink>{" "}
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page="Proxy Events"
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<ServerPaginationProvider
|
||||
url="/events/proxy"
|
||||
defaultPageSize={10}
|
||||
defaultFilters={defaultFilters}
|
||||
>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
</ServerPaginationProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function NetworkRoutes() {
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
|
||||
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
|
||||
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
|
||||
<span>
|
||||
We recommend using the new Networks concept to easier visualise
|
||||
and manage access to your resources.{" "}
|
||||
|
||||
@@ -12,14 +12,13 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
HelpCircle,
|
||||
Layers3Icon,
|
||||
MoreVertical,
|
||||
PencilLineIcon,
|
||||
ServerIcon,
|
||||
@@ -28,19 +27,29 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import React, { useMemo } from "react";
|
||||
import useUrlTab from "@/hooks/useUrlTab";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
} from "@/modules/networks/NetworkProvider";
|
||||
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
|
||||
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
|
||||
import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent";
|
||||
import { NetworkRoutingPeersTabContent } from "@/modules/networks/routing-peers/NetworkRoutingPeersTabContent";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
|
||||
import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
|
||||
|
||||
export default function NetworkDetailPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -53,17 +62,34 @@ export default function NetworkDetailPage() {
|
||||
useRedirect("/networks", false, !networkId);
|
||||
|
||||
return network && !isLoading ? (
|
||||
<NetworkOverview network={network} />
|
||||
<ReverseProxiesProvider initialNetwork={network}>
|
||||
<NetworkOverview network={network} />
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
<SkeletonNetwork />
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [networkModal, setNetworkModal] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>(`/networks/${network.id}/resources`);
|
||||
const { data: routers, isLoading: isRoutersLoading } = useFetchApi<
|
||||
NetworkRouter[]
|
||||
>(`/networks/${network.id}/routers`);
|
||||
|
||||
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
|
||||
const services = useMemo(
|
||||
() => flattenReverseProxies({ reverseProxies, network }),
|
||||
[reverseProxies, network],
|
||||
);
|
||||
|
||||
const [tab, setTab] = useUrlTab(
|
||||
["resources", "routing-peers", "services"],
|
||||
"resources",
|
||||
);
|
||||
|
||||
const isActive = !!(
|
||||
network?.routing_peers_count && network.routing_peers_count > 0
|
||||
@@ -71,56 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6 mb-4"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!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}
|
||||
/>
|
||||
</div>
|
||||
<NetworkProvider 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>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Separator />
|
||||
<ResourcesSection network={network} />
|
||||
<div className={"h-3"} />
|
||||
<Separator />
|
||||
<NetworkRoutingPeersSection network={network} />
|
||||
</NetworkProvider>
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { singularize } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
@@ -36,13 +37,14 @@ import {
|
||||
FlagIcon,
|
||||
Globe,
|
||||
History,
|
||||
ListIcon,
|
||||
MapPin,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
RadioTowerIcon,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
import React, { useMemo, useState } from "react";
|
||||
@@ -52,21 +54,27 @@ import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import type { Group } from "@/interfaces/Group";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
|
||||
import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import Link from "next/link";
|
||||
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
||||
|
||||
export default function PeerPage() {
|
||||
@@ -99,10 +107,12 @@ export default function PeerPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peer?.id} />
|
||||
</PeerProvider>
|
||||
return peer && peer.id && !isLoading ? (
|
||||
<ReverseProxiesProvider initialPeer={peer}>
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peer?.id} />
|
||||
</PeerProvider>
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
@@ -114,38 +124,60 @@ function PeerOverview() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<div className={"p-default py-6 pb-0"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={peer.ip} active />
|
||||
</Breadcrumbs>
|
||||
<PeerGeneralInformation />
|
||||
</div>
|
||||
<PeerOverviewTabs />
|
||||
<PeerSettingsProvider>
|
||||
<div className={"p-default py-6 pb-0"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={peer.ip} active />
|
||||
</Breadcrumbs>
|
||||
<PeerHeader />
|
||||
</div>
|
||||
<PeerOverviewTabs />
|
||||
</PeerSettingsProvider>
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PeerGeneralInformation = () => {
|
||||
const router = useRouter();
|
||||
type PeerSettingsContextType = {
|
||||
selectedGroups: Group[];
|
||||
setSelectedGroups: React.Dispatch<React.SetStateAction<Group[]>>;
|
||||
hasChanges: boolean;
|
||||
updatePeer: (newName?: string) => Promise<void>;
|
||||
name: string;
|
||||
setName: (name: string) => void;
|
||||
tab: string;
|
||||
setTab: (tab: string) => void;
|
||||
};
|
||||
|
||||
const PeerSettingsContext = React.createContext<PeerSettingsContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const usePeerSettings = () => {
|
||||
const context = React.useContext(PeerSettingsContext);
|
||||
if (!context) {
|
||||
throw new Error("usePeerSettings must be used within PeerSettingsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { peer, user, peerGroups, update } = usePeer();
|
||||
const { peer, peerGroups, update } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const [name, setName] = useState(peer.name);
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||
useGroupHelper({
|
||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||
peer,
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
]);
|
||||
@@ -175,7 +207,31 @@ const PeerGeneralInformation = () => {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PeerSettingsContext.Provider
|
||||
value={{
|
||||
selectedGroups,
|
||||
setSelectedGroups,
|
||||
hasChanges,
|
||||
updatePeer,
|
||||
name,
|
||||
setName,
|
||||
tab,
|
||||
setTab,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PeerSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerHeader = () => {
|
||||
const router = useRouter();
|
||||
const { peer, user } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings();
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const isOverviewTab = tab === "overview";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -236,49 +292,145 @@ const PeerGeneralInformation = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => router.push("/peers")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => updatePeer()}
|
||||
disabled={
|
||||
!hasChanges || !permission.peers.read || !permission.groups.update
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
{isOverviewTab && (
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => router.push("/peers")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => updatePeer()}
|
||||
disabled={
|
||||
!hasChanges ||
|
||||
!permission.peers.update ||
|
||||
!permission.groups.update
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOverviewTabs = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
|
||||
const { tab, setTab } = usePeerSettings();
|
||||
|
||||
const flatTargets = useMemo(
|
||||
() => flattenReverseProxies({ reverseProxies, peer }),
|
||||
[reverseProxies, peer],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pt-4 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"overview"}>
|
||||
<ListIcon size={16} />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsTrigger value={"network-routes"}>
|
||||
<NetworkIcon size={16} />
|
||||
Network Routes
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsTrigger value={"accessible-peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Accessible Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.services?.read && (
|
||||
<TabsTrigger value={"reverse-proxies"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird"
|
||||
/>
|
||||
{singularize("Services", flatTargets.length)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.delete && (
|
||||
<TabsTrigger value={"peer-job"}>
|
||||
<RadioTowerIcon size={16} />
|
||||
Remote Jobs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={"overview"} className={"pb-8"}>
|
||||
<PeerOverviewTabContent />
|
||||
</TabsContent>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.services?.read && (
|
||||
<TabsContent value={"reverse-proxies"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={flatTargets}
|
||||
isLoading={isServicesLoading}
|
||||
hideResourceColumn
|
||||
emptyTableTitle={"This peer has no services"}
|
||||
emptyTableDescription={
|
||||
"Add your services to this peer and securely expose them through NetBird's reverse proxy"
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer.id && permission.peers.delete && (
|
||||
<TabsContent value={"peer-job"} className={"pb-8"}>
|
||||
<PeerRemoteJobsSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOverviewTabContent = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
const { selectedGroups, setSelectedGroups } = usePeerSettings();
|
||||
|
||||
return (
|
||||
<div className={"px-8"}>
|
||||
<div
|
||||
className={
|
||||
"flex-wrap xl:flex-nowrap flex gap-10 w-full mt-5 max-w-6xl items-start"
|
||||
"flex-wrap xl:flex-nowrap flex gap-10 w-full items-start pt-2 max-w-6xl"
|
||||
}
|
||||
>
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
|
||||
<div className={"flex flex-col gap-8 lg:w-1/2 transition-all"}>
|
||||
<PeerExpirationSettings />
|
||||
|
||||
<PeerSSHToggle />
|
||||
|
||||
{/* Remote Access Buttons */}
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permission.groups.read && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
@@ -294,67 +446,21 @@ const PeerGeneralInformation = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PeerSSHToggle />
|
||||
|
||||
{/* Remote Access Buttons */}
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOverviewTabs = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [tab, setTab] = useState(
|
||||
permission.routes.read ? "network-routes" : "accessible-peers",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={(v) => setTab(v)}
|
||||
value={tab}
|
||||
className={"pt-10 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
{permission.routes.read && (
|
||||
<TabsTrigger value={"network-routes"}>
|
||||
<NetworkIcon size={16} />
|
||||
Network Routes
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsTrigger value={"accessible-peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Accessible Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.delete && (
|
||||
<TabsTrigger value={"peer-job"}>
|
||||
<RadioTowerIcon size={16} />
|
||||
Remote Jobs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{peer.id && permission.peers.delete && (
|
||||
<TabsContent value={"peer-job"} className={"pb-8"}>
|
||||
<PeerRemoteJobsSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -541,9 +647,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
peer.connected
|
||||
? "just now"
|
||||
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
|
||||
" (" +
|
||||
dayjs().to(peer.last_seen) +
|
||||
")"
|
||||
" (" +
|
||||
dayjs().to(peer.last_seen) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ function PeersBlockedView() {
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40"
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Custom Domains - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
70
src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx
Normal file
70
src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
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 { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
|
||||
import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const CustomDomainsTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyCustomDomainsPage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Reverse Proxy"}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/custom-domains"}
|
||||
label={"Custom Domains"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Domains</h1>
|
||||
<Paragraph>
|
||||
Add and manage custom domains for your reverse proxy services.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Custom Domains
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Custom Domains"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<ReverseProxiesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<CustomDomainsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</ReverseProxiesProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
15
src/app/(dashboard)/reverse-proxy/page.tsx
Normal file
15
src/app/(dashboard)/reverse-proxy/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ReverseProxyRedirectPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/reverse-proxy/services");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
8
src/app/(dashboard)/reverse-proxy/services/layout.tsx
Normal file
8
src/app/(dashboard)/reverse-proxy/services/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Services - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
83
src/app/(dashboard)/reverse-proxy/services/page.tsx
Normal file
83
src/app/(dashboard)/reverse-proxy/services/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
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 { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
|
||||
import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
|
||||
const ReverseProxyTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyServicesPage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Reverse Proxy"}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Services"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Services</h1>
|
||||
<Paragraph>
|
||||
Expose services securely through NetBird's reverse proxy.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
|
||||
Services
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
|
||||
{isNetBirdHosted() ? (
|
||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||
NetBird's Reverse Proxy is currently in beta and available at
|
||||
no cost during this period. Features, functionality, and pricing are
|
||||
subject to change upon release.
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||
NetBird's Reverse Proxy is currently in beta. <br /> Features
|
||||
and functionality are subject to change upon release.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Services"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<ReverseProxiesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ReverseProxyTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</ReverseProxiesProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export default function NetBirdSettings() {
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && (
|
||||
permission?.identity_providers?.read && (
|
||||
<VerticalTabs.Trigger value="identity-providers">
|
||||
<FingerprintIcon size={14} />
|
||||
Identity Providers
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function Team() {
|
||||
router.push("/team/users");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--toasts-before: 0;
|
||||
--lift: 1;
|
||||
}
|
||||
|
||||
html{
|
||||
@apply bg-nb-gray;
|
||||
}
|
||||
@@ -171,6 +176,25 @@ p {
|
||||
@apply m-0 p-0 box-border;
|
||||
}
|
||||
|
||||
/* Disable sonner's opacity fade-in for custom toasts, but respect visibility */
|
||||
[data-sonner-toast][data-visible="true"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Adjust sonner stacking: less shrink and less lift per toast */
|
||||
[data-sonner-toast][data-expanded="false"][data-front="false"] {
|
||||
--scale: calc(var(--toasts-before) * 0.03 - 1) !important;
|
||||
--lift-amount: calc(var(--lift) * 10px) !important;
|
||||
}
|
||||
|
||||
/* Override stacked toast removal to move up instead of down */
|
||||
[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'] {
|
||||
--y: translateY(calc(var(--lift) * -20%)) !important;
|
||||
|
||||
opacity: 0 !important;
|
||||
transition: transform 400ms ease, opacity 300ms ease !important;
|
||||
}
|
||||
|
||||
/* Control Center */
|
||||
.react-flow__node-groupNode .selected{
|
||||
|
||||
26
src/assets/icons/PeerOSIcon.tsx
Normal file
26
src/assets/icons/PeerOSIcon.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
os: string;
|
||||
};
|
||||
|
||||
export const PeerOSIcon = ({ os }: Props) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
19
src/assets/icons/PeerOrResourceIcon.tsx
Normal file
19
src/assets/icons/PeerOrResourceIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PeerOSIcon } from "./PeerOSIcon";
|
||||
import { ResourceIcon } from "./ResourceIcon";
|
||||
|
||||
type Props = {
|
||||
peer?: Peer;
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
|
||||
export const PeerOrResourceIcon = ({ peer, resource }: Props) => {
|
||||
return (
|
||||
<>
|
||||
{peer && <PeerOSIcon os={peer.os} />}
|
||||
{resource?.type && <ResourceIcon type={resource.type} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
20
src/assets/icons/ResourceIcon.tsx
Normal file
20
src/assets/icons/ResourceIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
type: "domain" | "host" | "subnet";
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ResourceIcon = ({ type, size = 15 }: Props) => {
|
||||
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} />;
|
||||
}
|
||||
};
|
||||
19
src/assets/icons/ReverseProxyIcon.tsx
Normal file
19
src/assets/icons/ReverseProxyIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function ReverseProxyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
fill={"currentColor"}
|
||||
>
|
||||
<path
|
||||
fill={"currentColor"}
|
||||
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
OidcProvider,
|
||||
} from "@axa-fr/react-oidc";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import loadConfig, { buildExtras } from "@utils/config";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { OIDCError } from "@/auth/OIDCError";
|
||||
import { SecureProvider } from "@/auth/SecureProvider";
|
||||
@@ -43,33 +42,6 @@ export default function OIDCProvider({ children }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
const params = useSearchParams()?.toString();
|
||||
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
|
||||
|
||||
useEffect(() => {
|
||||
const validParams = [
|
||||
"tab",
|
||||
"search",
|
||||
"id",
|
||||
"invite",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
"page",
|
||||
"page_size",
|
||||
"user",
|
||||
"port",
|
||||
];
|
||||
|
||||
try {
|
||||
const urlParams = new URLSearchParams(params);
|
||||
if (validParams.some((param) => urlParams.has(param))) {
|
||||
setQueryParams(params);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
const withCustomHistory = () => {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,24 @@ import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const QUERY_PARAMS_KEY = "netbird-query-params";
|
||||
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp"];
|
||||
const VALID_PARAMS = [
|
||||
"tab",
|
||||
"search",
|
||||
"id",
|
||||
"invite",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
"page",
|
||||
"page_size",
|
||||
"user",
|
||||
"port",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
@@ -10,6 +28,22 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
const { isAuthenticated, login } = useOidc();
|
||||
const currentPath = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath)) {
|
||||
localStorage.removeItem(QUERY_PARAMS_KEY);
|
||||
} else if (!isAuthenticated) {
|
||||
try {
|
||||
const params = window.location.search.substring(1);
|
||||
if (params) {
|
||||
const urlParams = new URLSearchParams(params);
|
||||
if (VALID_PARAMS.some((param) => urlParams.has(param))) {
|
||||
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(params));
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}, [isAuthenticated, currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
if (!isAuthenticated) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className=" pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
>(({ className, children }, ref) => {
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = wrapperRef.current?.closest("[data-state]");
|
||||
if (!el) return;
|
||||
|
||||
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isOpen ? "auto" : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className={cn("overflow-hidden text-sm", className)}
|
||||
>
|
||||
<div className="pt-0">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
@@ -25,8 +25,6 @@ export const Item = ({
|
||||
active,
|
||||
disabled = false,
|
||||
}: ItemProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -45,7 +43,13 @@ export const Item = ({
|
||||
)}
|
||||
>
|
||||
{icon && icon}
|
||||
{href ? <span onClick={() => router.push(href)}>{label}</span> : label}
|
||||
{href ? (
|
||||
<Link href={href} data-cy={"breadcrumb-item"}>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ export const buttonVariants = cva(
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"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-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
tertiary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
@@ -73,6 +73,9 @@ export const buttonVariants = cva(
|
||||
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
|
||||
"",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
|
||||
@@ -19,6 +19,8 @@ export const calloutVariants = cva(
|
||||
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
|
||||
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
|
||||
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
|
||||
success: "bg-green-400/15 border-green-400/20 text-green-100",
|
||||
error: "bg-red-500/10 border-red-400/20 text-red-100",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,11 +22,7 @@ export default function CopyToClipboardText({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
|
||||
!copied && "hover:opacity-90",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex gap-2 items-center group cursor-pointer", className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -34,27 +30,34 @@ export default function CopyToClipboardText({
|
||||
}}
|
||||
ref={wrapper}
|
||||
>
|
||||
{children}
|
||||
<span className="relative truncate">
|
||||
{children}
|
||||
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover:border-nb-gray-500 pointer-events-none" />
|
||||
</span>
|
||||
|
||||
{copied ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
)}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
"text-nb-gray-100 group-hover:opacity-100",
|
||||
!copied && "hidden",
|
||||
!alwaysShowIcon && !copied && "opacity-0",
|
||||
)}
|
||||
size={11}
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
"text-nb-gray-100 group-hover:opacity-100",
|
||||
copied && "hidden",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
size={11}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
value?: DateRange;
|
||||
onChange?: (range: DateRange | undefined) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const defaultRanges = {
|
||||
@@ -61,6 +62,7 @@ export function DatePickerWithRange({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: Readonly<Props>) {
|
||||
const isActive = useMemo(() => {
|
||||
return {
|
||||
@@ -120,6 +122,7 @@ export function DatePickerWithRange({
|
||||
<Button
|
||||
id="date"
|
||||
variant={"secondary"}
|
||||
disabled={disabled}
|
||||
className={cn("max-w-[260px] justify-start text-left font-normal")}
|
||||
>
|
||||
<CalendarIcon size={16} className={"shrink-0"} />
|
||||
|
||||
95
src/components/DeviceCard.tsx
Normal file
95
src/components/DeviceCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import { PeerOSIcon } from "@/assets/icons/PeerOSIcon";
|
||||
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type DeviceCardProps = {
|
||||
device?: Peer;
|
||||
resource?: NetworkResource;
|
||||
className?: string;
|
||||
address?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const DeviceCard = ({
|
||||
device,
|
||||
resource,
|
||||
className,
|
||||
address,
|
||||
description,
|
||||
}: DeviceCardProps) => {
|
||||
if (!device && !resource) return null;
|
||||
|
||||
const descriptionText = useMemo(() => {
|
||||
return description !== undefined
|
||||
? description
|
||||
: address || device?.ip || resource?.address;
|
||||
}, [description, address, device]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 text-nb-gray-200 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[230px]",
|
||||
!descriptionText && "py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
|
||||
"group-hover:bg-nb-gray-800 relative",
|
||||
)}
|
||||
>
|
||||
{device ? (
|
||||
<PeerOSIcon os={device.os} />
|
||||
) : resource?.type ? (
|
||||
<ResourceIcon type={resource.type} />
|
||||
) : null}
|
||||
|
||||
{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 top-[0.15rem] leading-tight relative"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"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>
|
||||
{descriptionText && (
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/ExternalLinkText.tsx
Normal file
45
src/components/ExternalLinkText.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
};
|
||||
|
||||
export default function ExternalLinkText({
|
||||
href,
|
||||
children,
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
alwaysShowIcon = false,
|
||||
}: Props) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex gap-2 items-center group/link cursor-pointer hover:opacity-90",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="relative">
|
||||
{children}
|
||||
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/link:border-nb-gray-500 pointer-events-none" />
|
||||
</span>
|
||||
<ExternalLinkIcon
|
||||
className={cn(
|
||||
"text-nb-gray-100 group-hover/link:opacity-100 shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
!alwaysShowIcon && "opacity-0",
|
||||
)}
|
||||
size={12}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,11 @@ export default function FancyToggleSwitch({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>{children && value ? children : null}</div>
|
||||
{children && value ? (
|
||||
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
69
src/components/HelpTooltip.tsx
Normal file
69
src/components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { TooltipVariants } from "@components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
interactive?: boolean;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
alignOffset?: number;
|
||||
sideOffset?: number;
|
||||
iconSize?: number;
|
||||
delayDuration?: number;
|
||||
} & TooltipVariants;
|
||||
export const HelpTooltip = ({
|
||||
content,
|
||||
children,
|
||||
interactive = false,
|
||||
className,
|
||||
variant = "default",
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
side = "top",
|
||||
alignOffset = 0,
|
||||
sideOffset,
|
||||
iconSize = 12,
|
||||
delayDuration = 300,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
interactive={interactive}
|
||||
side={side}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
delayDuration={delayDuration}
|
||||
variant={variant}
|
||||
className={
|
||||
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
|
||||
}
|
||||
content={
|
||||
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
|
||||
}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<HelpCircle
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</FullTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -127,6 +127,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
suffix && "!pr-16",
|
||||
icon && "!pl-10",
|
||||
"border",
|
||||
props.readOnly &&
|
||||
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -9,17 +9,34 @@ const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className, "select-none")}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type LabelProps = React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants> & {
|
||||
as?: "label" | "div";
|
||||
};
|
||||
|
||||
const Label = React.forwardRef<HTMLElement, LabelProps>(
|
||||
({ className, as = "label", children, ...props }, ref) => {
|
||||
const classes = cn(labelVariants(), className, "select-none");
|
||||
|
||||
if (as === "div") {
|
||||
return (
|
||||
<div ref={ref as React.Ref<HTMLDivElement>} className={classes}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref as React.Ref<HTMLLabelElement>}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
@@ -6,24 +6,26 @@ export const ListItem = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
|
||||
<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>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@ import { IconCircleX } from "@tabler/icons-react";
|
||||
import type { ErrorResponse } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import classNames from "classnames";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { CheckIcon, Loader2, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast, { type Toast } from "react-hot-toast";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface NotifyProps<T> {
|
||||
title: string;
|
||||
@@ -18,38 +18,95 @@ export interface NotifyProps<T> {
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
showOnlyError?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
interface NotificationProps<T> extends NotifyProps<T> {
|
||||
t: Toast;
|
||||
toastId: string | number;
|
||||
}
|
||||
|
||||
export default function Notification<T>({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
backgroundColor,
|
||||
t,
|
||||
toastId,
|
||||
promise,
|
||||
loadingTitle,
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
showOnlyError = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(!!promise);
|
||||
const [readyToDismiss, setReadyToDismiss] = useState(!promise);
|
||||
|
||||
const [toastDuration] = useState(duration);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const remainingRef = useRef(duration);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
const [preventSuccess, setPreventSuccess] = useState(false);
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
Math.max(0, remainingRef.current),
|
||||
);
|
||||
}, [toastId]);
|
||||
|
||||
const closeToast = () => {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
toast.dismiss(t.id);
|
||||
}, toastDuration);
|
||||
};
|
||||
const pauseTimer = useCallback(() => {
|
||||
if (!timerRef.current || !startTimeRef.current) return;
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
remainingRef.current = Math.max(
|
||||
0,
|
||||
remainingRef.current - (Date.now() - startTimeRef.current),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Watch for sonner's expanded state to pause/resume timer
|
||||
useEffect(() => {
|
||||
if (!readyToDismiss) return;
|
||||
|
||||
const toastEl = notificationRef.current?.closest(
|
||||
"[data-sonner-toast]",
|
||||
) as HTMLElement | null;
|
||||
if (!toastEl) {
|
||||
startTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const expanded = toastEl.getAttribute("data-expanded") === "true";
|
||||
if (expanded) {
|
||||
pauseTimer();
|
||||
} else {
|
||||
startTimer();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(toastEl, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-expanded"],
|
||||
});
|
||||
|
||||
// Start immediately if not expanded
|
||||
const expanded = toastEl.getAttribute("data-expanded") === "true";
|
||||
if (!expanded) startTimer();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [readyToDismiss, toastId, startTimer, pauseTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
// Run the promise
|
||||
@@ -57,8 +114,11 @@ export default function Notification<T>({
|
||||
promise
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
closeToast();
|
||||
if (preventSuccessToast) setPreventSuccess(true);
|
||||
if (showOnlyError || preventSuccessToast) {
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
setReadyToDismiss(true);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
const err = e as ErrorResponse;
|
||||
@@ -78,78 +138,79 @@ export default function Notification<T>({
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
closeToast();
|
||||
setReadyToDismiss(true);
|
||||
});
|
||||
} else {
|
||||
closeToast();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{t.visible && !preventSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
className={cn(
|
||||
"max-w-md w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-4"}>
|
||||
<div
|
||||
className={classNames(
|
||||
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
|
||||
loading
|
||||
? "bg-nb-gray-900"
|
||||
: error
|
||||
? "bg-red-500"
|
||||
: backgroundColor || "bg-green-500",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={14} className={"animate-spin"} />
|
||||
) : error ? (
|
||||
<IconCircleX size={24} />
|
||||
) : (
|
||||
icon || <CheckIcon size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col text-sm"}>
|
||||
<p>
|
||||
<span className={"font-semibold"}>
|
||||
{loading ? loadingTitle || title : title}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
|
||||
>
|
||||
{loading ? loadingMessage : error ? error : description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
const hideUntilError = showOnlyError && loading && !error;
|
||||
if (hideUntilError) return null;
|
||||
|
||||
<button
|
||||
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
return (
|
||||
<motion.div
|
||||
ref={notificationRef}
|
||||
initial={{ y: -20 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 20 }}
|
||||
data-toast-notification
|
||||
className="w-[28rem] pb-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-4"}>
|
||||
<div
|
||||
className={classNames(
|
||||
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
|
||||
loading
|
||||
? "bg-nb-gray-900"
|
||||
: error
|
||||
? "bg-red-500"
|
||||
: backgroundColor || "bg-green-500",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</div>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{loading ? (
|
||||
<Loader2 size={14} className={"animate-spin"} />
|
||||
) : error ? (
|
||||
<IconCircleX size={24} />
|
||||
) : (
|
||||
icon || <CheckIcon size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col text-sm"}>
|
||||
<p>
|
||||
<span className={"font-semibold"}>
|
||||
{loading ? loadingTitle || title : title}
|
||||
</span>
|
||||
</p>
|
||||
<p className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}>
|
||||
{loading ? loadingMessage : error ? error : description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
|
||||
onClick={() => toast.dismiss(toastId)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function notify<T>(props: NotifyProps<T>) {
|
||||
return toast.custom((t) => <Notification {...props} t={t} />, {
|
||||
return toast.custom((id) => <Notification {...props} toastId={id} />, {
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -40,10 +41,13 @@ import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -68,14 +72,21 @@ interface MultiSelectProps {
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
closeOnSelect?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
placeholder?: React.ReactNode | string;
|
||||
customTrigger?: React.ReactNode;
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
resourceIds?: string[];
|
||||
additionalResources?: NetworkResource[];
|
||||
policies?: Policy[];
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -94,6 +105,10 @@ export function PeerGroupSelector({
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showPeerCounter = true,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
closeOnSelect = false,
|
||||
resource,
|
||||
onResourceChange,
|
||||
placeholder = "Add or select group(s)...",
|
||||
@@ -102,11 +117,22 @@ export function PeerGroupSelector({
|
||||
side = "bottom",
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const resources = useMemo(() => {
|
||||
if (!additionalResources?.length) return fetchedResources;
|
||||
const additional = additionalResources.filter(
|
||||
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
|
||||
);
|
||||
return [...(fetchedResources || []), ...additional];
|
||||
}, [fetchedResources, additionalResources]);
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
@@ -229,7 +255,13 @@ export function PeerGroupSelector({
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
const [tab, setTab] = useState("groups");
|
||||
const getDefaultTab = (): PeerGroupSelectorTab => {
|
||||
if (tabOrder?.[0]) return tabOrder[0];
|
||||
if (hideGroupsTab) return showPeers ? "peers" : "resources";
|
||||
return "groups";
|
||||
};
|
||||
|
||||
const [tab, setTab] = useState<PeerGroupSelectorTab>(getDefaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -272,6 +304,9 @@ export function PeerGroupSelector({
|
||||
: undefined,
|
||||
);
|
||||
onChange([]);
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPeer = (peer?: Peer) => {
|
||||
@@ -281,6 +316,9 @@ export function PeerGroupSelector({
|
||||
type: "peer",
|
||||
});
|
||||
onChange([]);
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -306,7 +344,7 @@ export function PeerGroupSelector({
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
"disabled:pointer-events-none disabled:opacity-60 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
@@ -320,7 +358,14 @@ export function PeerGroupSelector({
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
resource={
|
||||
resources?.find((r) => r.id === resource.id) ??
|
||||
({
|
||||
id: resource.id,
|
||||
name: resource.id,
|
||||
type: resource.type,
|
||||
} as NetworkResource)
|
||||
}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -374,7 +419,9 @@ export function PeerGroupSelector({
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -438,11 +485,20 @@ export function PeerGroupSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
|
||||
<Tabs
|
||||
defaultValue={
|
||||
tabOrder?.[0] ??
|
||||
(hideGroupsTab ? (showPeers ? "peers" : "resources") : "groups")
|
||||
}
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as PeerGroupSelectorTab)}
|
||||
>
|
||||
<TabTriggers
|
||||
searchRef={searchRef}
|
||||
showPeers={showPeers}
|
||||
showResources={showResources}
|
||||
hideGroupsTab={hideGroupsTab}
|
||||
tabOrder={tabOrder}
|
||||
/>
|
||||
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
||||
<CommandGroup>
|
||||
@@ -535,12 +591,21 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
{policies && (
|
||||
<PolicyCounter
|
||||
group={option}
|
||||
policies={policies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
showPeerCounter && (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -562,7 +627,11 @@ export function PeerGroupSelector({
|
||||
<TabsContent value={"resources"} className={"p-0 my-0"}>
|
||||
<ResourcesList
|
||||
search={search}
|
||||
resources={resources}
|
||||
resources={
|
||||
resourceIds
|
||||
? resources?.filter((r) => resourceIds.includes(r.id))
|
||||
: resources
|
||||
}
|
||||
isLoading={isResourcesLoading}
|
||||
value={resource}
|
||||
onChange={selectResource}
|
||||
@@ -592,60 +661,89 @@ const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
}) => {
|
||||
if (!showResources && !showPeers) return null;
|
||||
const tabCount =
|
||||
(!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0);
|
||||
if (tabCount <= 1) return null;
|
||||
|
||||
const groupsTab = !hideGroupsTab && (
|
||||
<TabsTrigger
|
||||
key="groups"
|
||||
value={"groups"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<FolderGit2
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const resourcesTab = showResources && (
|
||||
<TabsTrigger
|
||||
key="resources"
|
||||
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>
|
||||
);
|
||||
|
||||
const peersTab = showPeers && (
|
||||
<TabsTrigger
|
||||
key="peers"
|
||||
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>
|
||||
);
|
||||
|
||||
const tabMap = {
|
||||
groups: groupsTab,
|
||||
peers: peersTab,
|
||||
resources: resourcesTab,
|
||||
};
|
||||
|
||||
if (tabOrder) {
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
{tabOrder.map((tab) => tabMap[tab])}
|
||||
</TabsList>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
<TabsTrigger
|
||||
value={"groups"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<FolderGit2
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{groupsTab}
|
||||
{resourcesTab}
|
||||
{peersTab}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -723,6 +821,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PolicyCounter = ({
|
||||
group,
|
||||
policies,
|
||||
}: {
|
||||
group: Group;
|
||||
policies: Policy[];
|
||||
}) => {
|
||||
const count = useMemo(() => {
|
||||
if (!group.id) return 0;
|
||||
return policies.filter((policy) => {
|
||||
const destinations = policy.rules?.[0]?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return destinations?.some((d) =>
|
||||
typeof d === "string" ? d === group.id : d.id === group.id,
|
||||
);
|
||||
}).length;
|
||||
}, [group.id, policies]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={14} className={"shrink-0"} />
|
||||
{count} {count === 1 ? "Policy" : "Policies"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
@@ -787,6 +918,7 @@ const ResourcesList = ({
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={onChange}
|
||||
estimatedItemHeight={42}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
return (
|
||||
@@ -896,6 +1028,7 @@ const PeersList = ({
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={onChange}
|
||||
estimatedItemHeight={42}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
if (!res?.id) return;
|
||||
@@ -904,7 +1037,7 @@ const PeersList = ({
|
||||
<Fragment key={res.id}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
useHover={true}
|
||||
useHover={false}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn(
|
||||
@@ -915,7 +1048,7 @@ const PeersList = ({
|
||||
}}
|
||||
>
|
||||
<PeerOperatingSystemIcon os={res.os} />
|
||||
<TextWithTooltip text={res?.name || ""} maxChars={20} />
|
||||
<TruncatedText text={res?.name || ""} maxWidth={"270px"} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
|
||||
123
src/components/PinCodeInput.tsx
Normal file
123
src/components/PinCodeInput.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, {
|
||||
ClipboardEvent,
|
||||
forwardRef,
|
||||
KeyboardEvent,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
export interface PinCodeInputRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
length?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
type?: "text" | "password";
|
||||
}
|
||||
|
||||
const PinCodeInput = forwardRef<PinCodeInputRef, Props>(function PinCodeInput(
|
||||
{ value, onChange, length = 6, disabled = false, className, type = "text" },
|
||||
ref,
|
||||
) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
inputRefs.current[0]?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const digits = value
|
||||
.split("")
|
||||
.concat(Array(length).fill(""))
|
||||
.slice(0, length);
|
||||
|
||||
const handleChange = (index: number, digit: string) => {
|
||||
if (!/^\d*$/.test(digit)) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = digit.slice(-1);
|
||||
const newValue = newDigits.join("").replace(/\s/g, "");
|
||||
onChange(newValue);
|
||||
|
||||
if (digit && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "ArrowLeft" && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "ArrowRight" && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
if (/^\d$/.test(e.key) && digits[index]) {
|
||||
e.preventDefault();
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = e.key;
|
||||
onChange(newDigits.join("").replace(/\s/g, ""));
|
||||
if (index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, length);
|
||||
onChange(pastedData);
|
||||
|
||||
const nextIndex = Math.min(pastedData.length, length - 1);
|
||||
inputRefs.current[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", className)}>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type={type}
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={handlePaste}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-[42px] h-[42px] text-center text-sm rounded-md",
|
||||
"dark:bg-nb-gray-900 border dark:border-nb-gray-700",
|
||||
"dark:placeholder:text-neutral-400/70",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PinCodeInput;
|
||||
@@ -188,7 +188,6 @@ export function PortSelector({
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
@@ -16,15 +17,18 @@ export const RadioCard = ({
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -10,10 +9,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const RadioGroup = ({ value, onChange, children }: Props) => {
|
||||
const [defaultValue] = useState(value);
|
||||
return (
|
||||
<RadixRadioGroup.Root
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className={
|
||||
|
||||
@@ -75,23 +75,59 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
extra?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, extra, icon, description, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
icon ? "pl-2 pr-8" : "pl-8 pr-2",
|
||||
description && "py-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{icon ? (
|
||||
<>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{extra}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
103
src/components/SettingCard.tsx
Normal file
103
src/components/SettingCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { PlusCircle, SquarePen } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type SettingCardItemProps = {
|
||||
label: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
enabled: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function SettingCardItem({
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
onClick,
|
||||
}: Readonly<SettingCardItemProps>) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
|
||||
}
|
||||
>
|
||||
<div className={"max-w-sm"}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{label}</Label>
|
||||
{enabled && (
|
||||
<SmallBadge
|
||||
text="Enabled"
|
||||
variant="green"
|
||||
size="md"
|
||||
className={"-top-[0.25rem]"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HelpText margin={false}>{description}</HelpText>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{enabled ? (
|
||||
<Button
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={onClick}
|
||||
>
|
||||
<SquarePen size={12} />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SettingCardProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function SettingCard({ children, className }: Readonly<SettingCardProps>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-nb-gray-920 bg-nb-gray-800/10 border rounded-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SettingCardWithItem = SettingCard as React.FC<Readonly<SettingCardProps>> & {
|
||||
Item: typeof SettingCardItem;
|
||||
};
|
||||
SettingCardWithItem.Item = SettingCardItem;
|
||||
|
||||
export default SettingCardWithItem;
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@utils/helpers";
|
||||
import classNames from "classnames";
|
||||
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
|
||||
export type SidebarItemProps = {
|
||||
@@ -36,8 +36,22 @@ export default function SidebarItem({
|
||||
labelClassName,
|
||||
visible,
|
||||
}: Readonly<SidebarItemProps>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const path = usePathname();
|
||||
|
||||
// Check if any child route is active (for collapsible items)
|
||||
const hasActiveChild = useMemo(() => {
|
||||
if (!collapsible || !href) return false;
|
||||
return path === href || path.startsWith(href + "/");
|
||||
}, [collapsible, href, path]);
|
||||
|
||||
const [open, setOpen] = React.useState(hasActiveChild);
|
||||
|
||||
// Open the collapsible if a child route becomes active
|
||||
useEffect(() => {
|
||||
if (hasActiveChild && !open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasActiveChild]);
|
||||
const router = useRouter();
|
||||
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
|
||||
useApplicationContext();
|
||||
@@ -48,6 +62,7 @@ export default function SidebarItem({
|
||||
? path == href
|
||||
: path.includes(href)
|
||||
: false;
|
||||
if (collapsible && href) return;
|
||||
if (collapsible && mobileNavOpen) return;
|
||||
if (collapsible && open) return;
|
||||
if (preventRedirect) return;
|
||||
@@ -66,7 +81,7 @@ export default function SidebarItem({
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<li className={"px-4 cursor-pointer list-none"}>
|
||||
<li className={"px-3 cursor-pointer list-none"}>
|
||||
<button
|
||||
className={classNames(
|
||||
"rounded-lg text-[.87rem] w-full relative font-normal",
|
||||
@@ -101,7 +116,7 @@ export default function SidebarItem({
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-4 whitespace-nowrap flex-1 w-full text-left",
|
||||
"px-3 whitespace-nowrap flex-1 w-full text-left",
|
||||
labelClassName,
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
MemoizedScrollArea,
|
||||
ScrollAreaViewport,
|
||||
} from "@components/ScrollArea";
|
||||
import { MemoizedScrollArea, ScrollAreaViewport } from "@components/ScrollArea";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import {
|
||||
|
||||
@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
|
||||
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -59,6 +60,7 @@ const ModalContent = React.forwardRef<
|
||||
children,
|
||||
showClose = true,
|
||||
maxWidthClass = "max-w-3xl",
|
||||
onPointerDownOutside,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -72,6 +74,19 @@ const ModalContent = React.forwardRef<
|
||||
className,
|
||||
maxWidthClass,
|
||||
)}
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent closing modal when clicking on toast notifications
|
||||
try {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.closest("[data-toast-notification]")) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
onPointerDownOutside?.(e);
|
||||
}}
|
||||
{...props}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
|
||||
export interface SelectOption {
|
||||
label: string | React.ReactNode;
|
||||
@@ -25,13 +26,16 @@ export interface SelectOption {
|
||||
}>;
|
||||
renderItem?: () => React.ReactNode;
|
||||
searchValue?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
popoverWidth?: "auto" | number;
|
||||
popoverWidth?: "auto" | "content" | number;
|
||||
popoverMinWidth?: number;
|
||||
options: SelectOption[];
|
||||
showSearch?: boolean;
|
||||
showValues?: boolean;
|
||||
@@ -44,6 +48,9 @@ interface SelectDropdownProps {
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: number;
|
||||
triggerClassName?: string;
|
||||
iconSize?: number;
|
||||
truncate?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -51,6 +58,7 @@ export function SelectDropdown({
|
||||
value,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
popoverMinWidth,
|
||||
options,
|
||||
showSearch = false,
|
||||
showValues = false,
|
||||
@@ -63,6 +71,9 @@ export function SelectDropdown({
|
||||
children,
|
||||
maxHeight,
|
||||
triggerClassName,
|
||||
iconSize = 14,
|
||||
truncate = false,
|
||||
compact = false,
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
@@ -102,15 +113,18 @@ export function SelectDropdown({
|
||||
|
||||
const SelectedItem = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
{selected?.icon && <selected.icon size={14} width={14} />}
|
||||
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
|
||||
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
truncate && "min-w-0",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{selected?.label}</span>
|
||||
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
|
||||
{selected?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -169,9 +183,18 @@ export function SelectDropdown({
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
|
||||
className={cn(
|
||||
"p-0 shadow-sm shadow-nb-gray-950 focus:outline-none",
|
||||
popoverWidth !== "content" && "w-full",
|
||||
)}
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
width:
|
||||
popoverWidth === "content"
|
||||
? "auto"
|
||||
: popoverWidth === "auto"
|
||||
? width
|
||||
: popoverWidth,
|
||||
minWidth: popoverMinWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
@@ -194,27 +217,30 @@ export function SelectDropdown({
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && (
|
||||
<div className={"text-center pb-2 px-3 text-nb-gray-400 text-xs"}>
|
||||
There are no results matching your search.
|
||||
</div>
|
||||
<DropdownInfoText className={"max-w-sm mx-auto px-4"}>
|
||||
There are no results matching your search. Please try a
|
||||
different search term.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
|
||||
!showSearch && "pt-2",
|
||||
"overflow-y-auto flex flex-col gap-1",
|
||||
compact ? "pl-1 pr-1" : "pl-2 pr-3",
|
||||
!showSearch && (compact ? "pt-1" : "pt-2"),
|
||||
)}
|
||||
style={{
|
||||
maxHeight: maxHeight ?? 380,
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={"grid grid-cols-1 gap-1 pb-2"}>
|
||||
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
|
||||
{filteredItems.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
iconSize={iconSize}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
@@ -234,11 +260,13 @@ const SelectDropdownItem = ({
|
||||
toggle,
|
||||
showValue = false,
|
||||
size = "sm",
|
||||
iconSize = 14,
|
||||
}: {
|
||||
option: SelectOption;
|
||||
toggle: (value: string) => void;
|
||||
showValue?: boolean;
|
||||
size: "xs" | "sm";
|
||||
iconSize?: number;
|
||||
}) => {
|
||||
const value = option.value || "" + option.label || "";
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
@@ -253,22 +281,34 @@ const SelectDropdownItem = ({
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div ref={elementRef} className={"transition-all"}>
|
||||
<div ref={elementRef} className={"transition-all w-full"}>
|
||||
{visible ? (
|
||||
<CommandItem
|
||||
value={option?.searchValue ?? value}
|
||||
ref={elementRef}
|
||||
className={"py-1 px-2"}
|
||||
onSelect={() => toggle(option.value)}
|
||||
className={"py-1 px-2 w-full"}
|
||||
onSelect={() => !option?.disabled && toggle(option.value)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
disabled={option?.disabled}
|
||||
>
|
||||
<div className={"flex items-center gap-2.5 p-1"}>
|
||||
{option.icon && <option.icon size={14} width={14} />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 p-1 w-full",
|
||||
option?.className,
|
||||
option?.disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{option.icon && (
|
||||
<div className={"shrink-0"}>
|
||||
<option.icon size={iconSize} width={iconSize} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{option?.renderItem && option.renderItem()}
|
||||
{!option?.renderItem && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
"flex flex-col text-sm font-medium w-full",
|
||||
size === "xs" && "text-xs",
|
||||
)}
|
||||
>
|
||||
|
||||
21
src/components/skeletons/SkeletonDeviceCard.tsx
Normal file
21
src/components/skeletons/SkeletonDeviceCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
|
||||
>
|
||||
<Skeleton height={36} width={36} />
|
||||
<div className={"flex flex-col pr-[1.15rem]"}>
|
||||
<Skeleton height={16} width={70} />
|
||||
<Skeleton height={16} width={140} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
src/components/skeletons/SkeletonNetwork.tsx
Normal file
42
src/components/skeletons/SkeletonNetwork.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
|
||||
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
|
||||
const [show, setShow] = useState(delay === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay === 0) return;
|
||||
const timer = setTimeout(() => setShow(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className={"p-default py-6 w-full"}>
|
||||
<Skeleton height={24} width={240} className={"mb-4"} />
|
||||
<div className={"mb-8 flex items-center gap-4"}>
|
||||
<Skeleton height={48} width={48} />
|
||||
<Skeleton height={20} width={200} />
|
||||
</div>
|
||||
<div className={"mb-4"}>
|
||||
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-4 mb-8"}>
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
|
||||
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
return (
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Skeleton height={24} width={200} className={"mb-6"} />
|
||||
<Skeleton height={32} width={110} className={"mb-10"} />
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,7 @@ declare module "@tanstack/table-core" {
|
||||
}
|
||||
interface SortingFns {
|
||||
checkbox: SortingFn<unknown>;
|
||||
datetime: SortingFn<unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
|
||||
return value.some((val) => val === rowValue);
|
||||
};
|
||||
|
||||
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
|
||||
const aConnected = rowA.original?.connected;
|
||||
const bConnected = rowB.original?.connected;
|
||||
if (aConnected !== bConnected) return aConnected ? 1 : -1;
|
||||
const a = dayjs(rowA.getValue(columnId)).valueOf();
|
||||
const b = dayjs(rowB.getValue(columnId)).valueOf();
|
||||
return a - b;
|
||||
};
|
||||
|
||||
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
|
||||
const valueA =
|
||||
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
|
||||
@@ -133,9 +143,10 @@ interface DataTableProps<TData, TValue> {
|
||||
className?: string;
|
||||
inset?: boolean;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
as?: "div" | "table";
|
||||
paginationClassName?: string;
|
||||
rowClassName?: string;
|
||||
rowClassName?: string | ((row: Row<TData>) => string);
|
||||
wrapperClassName?: string;
|
||||
tableClassName?: string;
|
||||
searchClassName?: string;
|
||||
@@ -150,6 +161,8 @@ interface DataTableProps<TData, TValue> {
|
||||
useRowId?: boolean;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
showResetFilterButton?: boolean;
|
||||
serverSidePagination?: boolean;
|
||||
hasServerSideFilters?: boolean;
|
||||
onFilterReset?: () => void;
|
||||
wrapperComponent?: React.ElementType;
|
||||
wrapperProps?: any;
|
||||
@@ -195,6 +208,7 @@ export function DataTable<TData, TValue>({
|
||||
tableClassName,
|
||||
inset,
|
||||
isLoading = false,
|
||||
isFetching = false,
|
||||
paginationClassName,
|
||||
rowClassName,
|
||||
wrapperClassName,
|
||||
@@ -211,6 +225,8 @@ export function DataTable<TData, TValue>({
|
||||
useRowId,
|
||||
headingTarget,
|
||||
showResetFilterButton = true,
|
||||
serverSidePagination = false,
|
||||
hasServerSideFilters,
|
||||
onFilterReset,
|
||||
showSearchAndFilters = true,
|
||||
wrapperProps,
|
||||
@@ -236,6 +252,19 @@ export function DataTable<TData, TValue>({
|
||||
const path = usePathname();
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
if (!serverSidePagination) return;
|
||||
if (isFetching && !isLoading) {
|
||||
overlayTimer.current = setTimeout(() => setShowOverlay(true), 500);
|
||||
} else {
|
||||
clearTimeout(overlayTimer.current);
|
||||
setShowOverlay(false);
|
||||
}
|
||||
return () => clearTimeout(overlayTimer.current);
|
||||
}, [serverSidePagination, isFetching, isLoading]);
|
||||
|
||||
const [localColumnFilters, setLocalColumnFilters] =
|
||||
useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
@@ -277,6 +306,7 @@ export function DataTable<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualSorting: serverSidePagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
@@ -304,6 +334,7 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
datetime: datetimeSort,
|
||||
},
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
@@ -411,12 +442,7 @@ export function DataTable<TData, TValue>({
|
||||
return (
|
||||
<div className={cn("relative table-fixed-scroll", className)}>
|
||||
{showSearchAndFilters && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-4 gap-y-6 flex-wrap",
|
||||
!minimal && "p-default",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex gap-x-4 gap-y-6", !minimal && "p-default")}>
|
||||
<DataTableGlobalSearch
|
||||
className={searchClassName}
|
||||
disabled={false} // Never disable the search input
|
||||
@@ -439,10 +465,14 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
{children?.(table)}
|
||||
{showResetFilterButton && (
|
||||
<DataTableResetFilterButton onClick={resetFilters} table={table} />
|
||||
<DataTableResetFilterButton
|
||||
onClick={resetFilters}
|
||||
table={table}
|
||||
hasServerSideFilters={hasServerSideFilters}
|
||||
/>
|
||||
)}
|
||||
<div className={"flex gap-4 flex-wrap grow"}>
|
||||
<div className={"flex gap-4 flex-wrap"}></div>
|
||||
<div className={"flex gap-4 grow"}>
|
||||
<div className={"flex gap-4"}></div>
|
||||
{rightSide?.(table)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,135 +480,141 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
{aboveTable?.(table)}
|
||||
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{isLoading ? (
|
||||
<TableContentSkeleton />
|
||||
) : !hasInitialData ? (
|
||||
getStartedCard
|
||||
) : (
|
||||
<TableComponent
|
||||
className={cn("relative mt-6", tableClassName)}
|
||||
minimal={minimal}
|
||||
>
|
||||
{showHeader && as == "table" && (
|
||||
<TableHeaderComponent minimal={minimal}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRowComponent key={headerGroup.id} minimal={minimal}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRowComponent>
|
||||
))}
|
||||
</TableHeaderComponent>
|
||||
)}
|
||||
|
||||
<TableBodyComponent
|
||||
className={cn(
|
||||
"relative",
|
||||
data == undefined && "blur-sm",
|
||||
wrapperClassName,
|
||||
)}
|
||||
<div className="relative">
|
||||
{showOverlay && (
|
||||
<div className="absolute inset-0 bg-nb-gray-950/60 z-10 rounded-md animate-pulse" />
|
||||
)}
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{isLoading ? (
|
||||
<TableContentSkeleton />
|
||||
) : !hasInitialData && !hasServerSideFilters ? (
|
||||
getStartedCard
|
||||
) : (
|
||||
<TableComponent
|
||||
className={cn("relative mt-6", tableClassName)}
|
||||
minimal={minimal}
|
||||
>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
const rowId = row.original.id ?? row.id;
|
||||
const isExpanded = accordion?.includes(rowId);
|
||||
const rowContent = (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={rowId}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"relative group/accordion",
|
||||
(onRowClick || expandedRow) && "cursor-pointer",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={isExpanded ? "opened" : "closed"}
|
||||
onClick={(e) => {
|
||||
if (expandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(rowId)) {
|
||||
return prev.filter(
|
||||
(item) => item !== rowId,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), rowId];
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
{showHeader && as == "table" && (
|
||||
<TableHeaderComponent minimal={minimal}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRowComponent key={headerGroup.id} minimal={minimal}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</TableRowComponent>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRowComponent>
|
||||
))}
|
||||
</TableHeaderComponent>
|
||||
)}
|
||||
|
||||
{expandedRow && isExpanded && (
|
||||
<TableBodyComponent
|
||||
className={cn(
|
||||
"relative",
|
||||
data == undefined && "blur-sm",
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
const rowId = row.original.id ?? row.id;
|
||||
const isExpanded = accordion?.includes(rowId);
|
||||
const rowContent = (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
data-row-id={rowId}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"relative group/accordion",
|
||||
(onRowClick || expandedRow) && "cursor-pointer",
|
||||
typeof rowClassName === "function"
|
||||
? rowClassName(row)
|
||||
: rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={isExpanded ? "opened" : "closed"}
|
||||
onClick={(e) => {
|
||||
if (expandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(rowId)) {
|
||||
return prev.filter((item) => item !== rowId);
|
||||
} else {
|
||||
return [...(prev ?? []), rowId];
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</TableRowComponent>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return renderRow
|
||||
? renderRow(row.original, rowContent)
|
||||
: rowContent;
|
||||
})
|
||||
{expandedRow && isExpanded && (
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
typeof rowClassName === "function"
|
||||
? rowClassName(row)
|
||||
: rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return renderRow
|
||||
? renderRow(row.original, rowContent)
|
||||
: rowContent;
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
<TableCellComponent
|
||||
@@ -589,10 +625,11 @@ export function DataTable<TData, TValue>({
|
||||
</TableCellComponent>
|
||||
</TableRowUnstyledComponent>
|
||||
)}
|
||||
</TableBodyComponent>
|
||||
</TableComponent>
|
||||
)}
|
||||
</TableWrapper>
|
||||
</TableBodyComponent>
|
||||
</TableComponent>
|
||||
)}
|
||||
</TableWrapper>
|
||||
</div>
|
||||
|
||||
<div className={paginationClassName}>
|
||||
<DataTablePagination
|
||||
@@ -603,7 +640,13 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTableHeadingPortal table={table} headingTarget={headingTarget} />
|
||||
<DataTableHeadingPortal
|
||||
table={table}
|
||||
headingTarget={headingTarget}
|
||||
totalRecords={totalRecords}
|
||||
manualPagination={manualPagination}
|
||||
hasActiveFilters={hasServerSideFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import type { Column } from "@tanstack/table-core";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||
|
||||
type Props = {
|
||||
column: Column<any>;
|
||||
@@ -12,6 +13,9 @@ type Props = {
|
||||
tooltip?: string | React.ReactNode;
|
||||
center?: boolean;
|
||||
className?: string;
|
||||
sorting?: boolean;
|
||||
onSort?: () => void;
|
||||
name?: string;
|
||||
};
|
||||
export default function DataTableHeader({
|
||||
children,
|
||||
@@ -19,23 +23,44 @@ export default function DataTableHeader({
|
||||
tooltip,
|
||||
center,
|
||||
className,
|
||||
sorting = true,
|
||||
onSort,
|
||||
name,
|
||||
}: Props) {
|
||||
const serverPagination = useOptionalServerPagination();
|
||||
|
||||
const handleSort = () => {
|
||||
if (onSort) {
|
||||
onSort();
|
||||
} else {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
}
|
||||
if (name && serverPagination?.setSort) {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
serverPagination.setSort(name, direction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullTooltip content={tooltip} disabled={!tooltip}>
|
||||
<div
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
onClick={sorting ? handleSort : undefined}
|
||||
className={cn(
|
||||
"flex items-center whitespace-nowrap cursor-pointer gap-2 dark:text-gray-400 dark:hover:text-gray-300 transition-all select-none hover:text-nb-gray text-xs tracking-wide",
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
|
||||
sorting &&
|
||||
"cursor-pointer dark:hover:text-gray-300 hover:text-nb-gray",
|
||||
center && "justify-center w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
)}
|
||||
{sorting &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
))}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
);
|
||||
|
||||
@@ -6,27 +6,57 @@ import { createPortal } from "react-dom";
|
||||
type Props<TData> = {
|
||||
table: Table<TData> | null;
|
||||
headingTarget?: HTMLHeadingElement | null;
|
||||
totalRecords?: number;
|
||||
manualPagination?: boolean;
|
||||
hasActiveFilters?: boolean;
|
||||
};
|
||||
|
||||
export const DataTableHeadingPortal = function <TData>({
|
||||
table,
|
||||
headingTarget,
|
||||
totalRecords,
|
||||
manualPagination,
|
||||
hasActiveFilters,
|
||||
}: Props<TData>) {
|
||||
const hasMounted = useRef(false);
|
||||
const initialTotalRecords = useRef<number | undefined>(undefined);
|
||||
|
||||
if (
|
||||
manualPagination &&
|
||||
totalRecords !== undefined &&
|
||||
initialTotalRecords.current === undefined
|
||||
) {
|
||||
initialTotalRecords.current = totalRecords;
|
||||
}
|
||||
|
||||
if (!headingTarget) return;
|
||||
if (!hasMounted.current) hasMounted.current = true;
|
||||
|
||||
const totalItems = table?.getPreFilteredRowModel().rows.length;
|
||||
const filteredItems = table?.getFilteredRowModel().rows.length;
|
||||
const filteredItems = manualPagination
|
||||
? totalRecords
|
||||
: table?.getFilteredRowModel().rows.length;
|
||||
|
||||
const getTotalRecords = () => {
|
||||
if (Number(initialTotalRecords.current) < Number(filteredItems)) {
|
||||
initialTotalRecords.current = filteredItems;
|
||||
return filteredItems;
|
||||
}
|
||||
return initialTotalRecords.current;
|
||||
};
|
||||
|
||||
const totalItems = manualPagination
|
||||
? getTotalRecords()
|
||||
: table?.getPreFilteredRowModel().rows.length;
|
||||
|
||||
if (!totalItems || totalItems == 1) return;
|
||||
|
||||
const hasAnyFiltersActive =
|
||||
table &&
|
||||
!(
|
||||
table?.getState().columnFilters.length <= 0 &&
|
||||
table?.getState().globalFilter === ""
|
||||
);
|
||||
const hasAnyFiltersActive = manualPagination
|
||||
? hasActiveFilters ?? totalRecords !== initialTotalRecords.current
|
||||
: table &&
|
||||
!(
|
||||
table?.getState().columnFilters.length <= 0 &&
|
||||
table?.getState().globalFilter === ""
|
||||
);
|
||||
|
||||
const portalContainer = document.createElement("span");
|
||||
headingTarget.prepend(portalContainer);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
export const AIButton = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"animated-gradient-bg gap-2 flex items-center justify-center text-sm font-medium p-[2px] rounded-md group"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center justify-center w-full h-full gap-2 bg-nb-gray-930/70 px-3 py-2.5 rounded-md"
|
||||
}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
AI Rule Wizard
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
iconSize?: number;
|
||||
popoverWidth?: "auto" | "content" | number;
|
||||
truncate?: boolean;
|
||||
};
|
||||
export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
|
||||
const { countries, isLoading } = useCountries();
|
||||
|
||||
const countryList = useMemo(() => {
|
||||
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
}) =>
|
||||
createElement(RoundedFlag, {
|
||||
country: country.country_code,
|
||||
size: 20,
|
||||
size: iconSize,
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
searchPlaceholder={"Search country..."}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
iconSize={iconSize}
|
||||
options={countryList || []}
|
||||
popoverWidth={popoverWidth}
|
||||
truncate={truncate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,18 +2,17 @@ import { cn } from "@utils/helpers";
|
||||
import LoadingIcon from "@/assets/icons/LoadingIcon";
|
||||
|
||||
type Props = {
|
||||
height?: "screen" | "auto";
|
||||
fullScreen?: boolean
|
||||
};
|
||||
export default function FullScreenLoading({ height = "screen" }: Props) {
|
||||
export default function FullScreenLoading({ fullScreen = true }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-screen",
|
||||
height == "screen" && "h-screen",
|
||||
height == "auto" && "h-auto",
|
||||
fullScreen && "h-screen",
|
||||
)}
|
||||
>
|
||||
<LoadingIcon className={"fill-netbird"} size={44} />
|
||||
<LoadingIcon className="fill-netbird" size={44} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function GetStartedTest({
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>{button}</div>
|
||||
{button && <div>{button}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph className={"text-sm justify-center pb-5 px-8"}>
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -37,6 +38,7 @@ export default function MultipleGroups({
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
disableRedirect = false,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -64,6 +66,7 @@ export default function MultipleGroups({
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
showNewBadge={true}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
@@ -101,7 +104,7 @@ export default function MultipleGroups({
|
||||
return (
|
||||
group && (
|
||||
<div
|
||||
key={group.id}
|
||||
key={group?.id || group?.name}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
@@ -110,16 +113,23 @@ export default function MultipleGroups({
|
||||
group={group}
|
||||
className={"py-0"}
|
||||
textClassName={"py-1.5"}
|
||||
redirectToGroupPage={true}
|
||||
showNewBadge={true}
|
||||
redirectToGroupPage={!disableRedirect}
|
||||
redirectGroupTab={redirectGroupTab}
|
||||
></GroupBadge>
|
||||
<ArrowRightIcon size={14} />
|
||||
{showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
) : showUsers ? (
|
||||
<UserCountStack group={group} />
|
||||
) : (
|
||||
<PeerCountBadge group={group} />
|
||||
<PeerCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
};
|
||||
export const NewBadge = ({ text = "NEW" }: Props) => {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { FilterX } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
@@ -54,7 +55,7 @@ export default function NoResults({
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<div
|
||||
className={
|
||||
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/70 w-full h-full overflow-hidden top-0"
|
||||
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/50 w-full h-full overflow-hidden top-0"
|
||||
}
|
||||
></div>
|
||||
<div
|
||||
@@ -66,18 +67,21 @@ export default function NoResults({
|
||||
<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={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"
|
||||
}
|
||||
>
|
||||
{icon ? icon : <FilterX size={24} />}
|
||||
<div className={"flex items-center justify-center mb-6"}>
|
||||
<SquareIcon
|
||||
icon={icon ? icon : <FilterX size={24} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"text-center"}>
|
||||
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
|
||||
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import Badge, { BadgeVariants } from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { EyeIcon, MonitorSmartphoneIcon, SquarePen } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { AssignPeerToGroupModal } from "@/modules/groups/AssignPeerToGroupModal";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
group?: Group;
|
||||
useSave?: boolean;
|
||||
onAssignmentChange?: (group: Group) => void;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
export default function PeerBadge({
|
||||
children,
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
useSave = true,
|
||||
onAssignmentChange,
|
||||
}: Props) {
|
||||
const [editGroupPeersModal, setEditGroupPeersModal] = useState(false);
|
||||
|
||||
const { dropdownOptions, addDropdownOptions } = useGroups();
|
||||
|
||||
const currentGroup = useMemo(() => {
|
||||
return dropdownOptions?.find((g) => g.name === group?.name);
|
||||
}, [group, dropdownOptions]);
|
||||
|
||||
const peerCount = useMemo(() => {
|
||||
let peerCount = currentGroup?.peers_count ?? 0;
|
||||
let countedPeers = currentGroup?.peers?.length ?? 0;
|
||||
if (peerCount !== countedPeers) {
|
||||
peerCount = countedPeers;
|
||||
}
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const updateGroupOptions = (g: Group) => {
|
||||
addDropdownOptions([g]);
|
||||
onAssignmentChange && onAssignmentChange(g);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentGroup && editGroupPeersModal && (
|
||||
<AssignPeerToGroupModal
|
||||
useSave={useSave}
|
||||
group={currentGroup}
|
||||
onUpdate={(g) => updateGroupOptions(g)}
|
||||
open={editGroupPeersModal}
|
||||
setOpen={setEditGroupPeersModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(className, "px-3 gap-2 whitespace-nowrap")}
|
||||
onClick={(e) => {
|
||||
if (!currentGroup) return;
|
||||
e.stopPropagation();
|
||||
setEditGroupPeersModal(true);
|
||||
}}
|
||||
useHover={!!currentGroup}
|
||||
>
|
||||
{!currentGroup && <MonitorSmartphoneIcon size={12} />}
|
||||
{currentGroup ? <>{peerCount} Peer(s)</> : children}
|
||||
|
||||
{currentGroup && (
|
||||
<>
|
||||
{currentGroup.name == "All" ? (
|
||||
<EyeIcon size={12} />
|
||||
) : (
|
||||
<SquarePen size={12} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions, groups } = useGroups();
|
||||
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const canRedirect = !!group?.id && group?.name !== "All";
|
||||
const canRedirect =
|
||||
!!group?.id && group?.name !== "All" && !disableRedirect;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
|
||||
const showResources = resourcesCount > 0 && peerCount === 0;
|
||||
|
||||
return showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
|
||||
) : (
|
||||
<Badge
|
||||
variant={variant}
|
||||
|
||||
@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function ResourceCountBadge({ group }: Props) {
|
||||
export default function ResourceCountBadge({
|
||||
group,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const hasId = !!group?.id;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disableRedirect) return;
|
||||
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ const smallBadgeVariants = cva("", {
|
||||
white: "bg-white/20 border border-white/10 text-white",
|
||||
sky: "bg-sky-900 border border-sky-500/20 text-white",
|
||||
netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300",
|
||||
yellow: "bg-yellow-900 border border-yellow-500/20 text-yellow-400",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"text-[0.4rem] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
|
||||
md: "text-[0.55rem] relative -top-[.25px] leading-[0] py-[0.45rem] px-1 rounded-[3px]",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -27,15 +33,10 @@ export const SmallBadge = ({
|
||||
textClassName,
|
||||
variant = "green",
|
||||
children,
|
||||
size = "default",
|
||||
}: Props) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
smallBadgeVariants({ variant }),
|
||||
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn(smallBadgeVariants({ variant, size }), className)}>
|
||||
{children}
|
||||
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
|
||||
</span>
|
||||
|
||||
@@ -4,22 +4,38 @@ import React, { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
children?: React.ReactNode;
|
||||
tooltipContent?: React.ReactNode;
|
||||
className?: string;
|
||||
maxChars?: number;
|
||||
maxWidth?: string; // Optional CSS width value
|
||||
hideTooltip?: boolean;
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
sideOffset?: number;
|
||||
};
|
||||
|
||||
export default function TruncatedText({
|
||||
text,
|
||||
children,
|
||||
tooltipContent,
|
||||
className,
|
||||
maxChars = 40,
|
||||
maxWidth,
|
||||
hideTooltip = false,
|
||||
align,
|
||||
alignOffset = 20,
|
||||
side,
|
||||
sideOffset = 4,
|
||||
}: Readonly<Props>) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const measureRef = React.useRef<HTMLSpanElement>(null);
|
||||
|
||||
const hasCustomChildren = !!children;
|
||||
const content = children ?? text;
|
||||
|
||||
const charCount = useMemo(() => {
|
||||
if (!text) return 0;
|
||||
@@ -27,12 +43,17 @@ export default function TruncatedText({
|
||||
}, [text]);
|
||||
|
||||
// Check for overflow on mount and when text/maxWidth changes
|
||||
// When custom children are provided, use a hidden measurement element
|
||||
// to detect overflow independently of children's own truncation
|
||||
React.useEffect(() => {
|
||||
const element = contentRef.current;
|
||||
if (element) {
|
||||
setIsOverflowing(element.scrollWidth > element.clientWidth);
|
||||
const container = contentRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (hasCustomChildren && container && measure) {
|
||||
setIsOverflowing(measure.scrollWidth > container.clientWidth);
|
||||
} else if (container) {
|
||||
setIsOverflowing(container.scrollWidth > container.clientWidth);
|
||||
}
|
||||
}, [text, maxWidth]);
|
||||
}, [text, children, maxWidth, hasCustomChildren]);
|
||||
|
||||
// If maxWidth is provided, use overflow detection
|
||||
// Otherwise, fall back to character count logic
|
||||
@@ -44,11 +65,28 @@ export default function TruncatedText({
|
||||
? { maxWidth }
|
||||
: { maxWidth: `${maxChars - 2}ch` };
|
||||
|
||||
const measureElement = hasCustomChildren && text && (
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="absolute invisible whitespace-nowrap pointer-events-none h-0 overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<div className="w-full min-w-0 inline-block" style={containerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0 inline-block",
|
||||
hasCustomChildren && "relative",
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
{measureElement}
|
||||
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||
{text}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -57,25 +95,36 @@ export default function TruncatedText({
|
||||
return (
|
||||
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className="w-full min-w-0 inline-block" style={containerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0 inline-block",
|
||||
hasCustomChildren && "relative",
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
{measureElement}
|
||||
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||
{text}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
alignOffset={20}
|
||||
sideOffset={4}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(className, "px-3 py-1.5")}
|
||||
>
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
{tooltipContent ?? (
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
||||
import announcementFile from "../../announcements.json";
|
||||
|
||||
const ANNOUNCEMENTS_URL =
|
||||
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
|
||||
@@ -64,7 +65,9 @@ const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
|
||||
|
||||
let raw: Announcement[];
|
||||
|
||||
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
if (isLocalDev()) {
|
||||
raw = announcementFile as Announcement[];
|
||||
} else if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
raw = stored.announcements;
|
||||
} else {
|
||||
const response = await fetch(ANNOUNCEMENTS_URL);
|
||||
|
||||
@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
|
||||
countries: Country[] | undefined;
|
||||
isLoading: boolean;
|
||||
getRegionByPeer: (peer: Peer) => string;
|
||||
getRegionText: (country_code: string, city_name: string) => string;
|
||||
getRegionText: (
|
||||
country_code: string,
|
||||
city_name: string,
|
||||
subdivision_code?: string,
|
||||
) => string;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
const getRegionByPeer = (peer: Peer) => "Unknown";
|
||||
const getRegionText = (country_code: string, city_name: string) => "Unknown";
|
||||
const getRegionText = (
|
||||
country_code: string,
|
||||
city_name: string,
|
||||
subdivision_code?: string,
|
||||
) => "Unknown";
|
||||
|
||||
return isRestricted ? (
|
||||
<CountryContext.Provider
|
||||
@@ -47,12 +55,14 @@ function CountryProviderContent({ children }: Props) {
|
||||
);
|
||||
|
||||
const getRegionText = useCallback(
|
||||
(country_code: string, city_name: string) => {
|
||||
(country_code: string, city_name: string, subdivision_code?: string) => {
|
||||
if (!countries) return "Unknown";
|
||||
const country = countries.find((c) => c.country_code === country_code);
|
||||
if (!country) return "Unknown";
|
||||
if (!city_name) return country.country_name;
|
||||
return `${country.country_name}, ${city_name}`;
|
||||
const parts = [country.country_name];
|
||||
if (subdivision_code) parts.push(subdivision_code);
|
||||
if (city_name) parts.push(city_name);
|
||||
return parts.join(", ");
|
||||
},
|
||||
[countries],
|
||||
);
|
||||
|
||||
@@ -27,6 +27,8 @@ type DialogOptions = {
|
||||
type?: "default" | "warning" | "danger" | "center";
|
||||
children?: React.ReactNode;
|
||||
maxWidthClass?: string;
|
||||
hideIcon?: boolean;
|
||||
center?: boolean;
|
||||
};
|
||||
|
||||
export default function DialogProvider({ children }: Props) {
|
||||
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
center={dialogOptions.center ?? dialogOptions.type == "center"}
|
||||
title={dialogOptions.title || "Confirmation"}
|
||||
margin={"mt-1"}
|
||||
description={
|
||||
dialogOptions.description ||
|
||||
"Are you sure you want to continue? This action cannot be undone."
|
||||
}
|
||||
icon={dialogTypes[dialogOptions.type || "default"]}
|
||||
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
|
||||
color={
|
||||
dialogOptions.type == "default"
|
||||
? "blue"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
@@ -18,18 +23,115 @@ const PoliciesContext = React.createContext(
|
||||
message?: string,
|
||||
) => void;
|
||||
createPolicy: (policy: Policy) => Promise<Policy>;
|
||||
createPoliciesForResource: (
|
||||
policies: Policy[],
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => Promise<void>;
|
||||
openEditPolicyModal: (policy: Policy, tab?: string) => void;
|
||||
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
|
||||
serializeRules: (
|
||||
rules: Policy["rules"],
|
||||
enabled?: boolean,
|
||||
) => Policy["rules"];
|
||||
},
|
||||
);
|
||||
|
||||
export default function PoliciesProvider({ children }: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const request = useApiCall<Policy>("/policies");
|
||||
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const [initialPolicyTab, setInitialPolicyTab] = useState("");
|
||||
|
||||
const createPolicy = async (policy: Policy) => request.post(policy);
|
||||
|
||||
const createPolicyForResource = async (
|
||||
policy: Policy,
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => {
|
||||
const rule = policy.rules[0];
|
||||
|
||||
const allGroups = [...(knownGroups || []), ...(groups || [])];
|
||||
const resolveGroup = async (g: Group | string): Promise<string> => {
|
||||
if (typeof g === "string") return g;
|
||||
if (g.id) return g.id;
|
||||
const existing = allGroups.find((eg) => eg.name === g.name);
|
||||
if (existing?.id) return existing.id;
|
||||
const created = await createOrUpdateGroup(g);
|
||||
return created.id!;
|
||||
};
|
||||
|
||||
const sources = await Promise.all(
|
||||
(rule.sources ?? []).map(resolveGroup),
|
||||
).then((ids) => ids.filter(Boolean) as string[]);
|
||||
|
||||
const destinations = rule.destinationResource
|
||||
? undefined
|
||||
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
|
||||
(ids) => ids.filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
const destinationResource = rule.destinationResource
|
||||
? { id: resource.id, type: resource.type }
|
||||
: undefined;
|
||||
|
||||
return createPolicy({
|
||||
...policy,
|
||||
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
|
||||
typeof c === "string" ? c : c.id,
|
||||
),
|
||||
rules: [
|
||||
{
|
||||
...rule,
|
||||
sources,
|
||||
destinations,
|
||||
destinationResource,
|
||||
},
|
||||
],
|
||||
} as Policy);
|
||||
};
|
||||
|
||||
const createPoliciesForResource = async (
|
||||
newPolicies: Policy[],
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => {
|
||||
const policiesToCreate = newPolicies.filter((p) => !p.id);
|
||||
if (policiesToCreate.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
policiesToCreate.map((p) =>
|
||||
createPolicyForResource(p, resource, knownGroups),
|
||||
),
|
||||
);
|
||||
await mutate("/policies");
|
||||
};
|
||||
|
||||
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
|
||||
rules = cloneDeep(rules);
|
||||
rules.forEach((rule) => {
|
||||
if (enabled !== undefined) rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((s) => {
|
||||
const group = s as Group;
|
||||
return group.id ?? s;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((d) => {
|
||||
const group = d as Group;
|
||||
return group.id ?? d;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) rule.destinations = null;
|
||||
if (rule.sourceResource) rule.sources = null;
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
const updatePolicy = async (
|
||||
policy: Policy,
|
||||
toUpdate: Partial<Policy>,
|
||||
@@ -62,6 +164,20 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
|
||||
const promise = request.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
onSuccess?.();
|
||||
});
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully deleted.",
|
||||
promise,
|
||||
loadingMessage: "Deleting policy...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy, tab?: string) => {
|
||||
setCurrentPolicy(policy);
|
||||
tab && setInitialPolicyTab(tab);
|
||||
@@ -70,7 +186,14 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
|
||||
return (
|
||||
<PoliciesContext.Provider
|
||||
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
|
||||
value={{
|
||||
updatePolicy,
|
||||
createPolicy,
|
||||
createPoliciesForResource,
|
||||
openEditPolicyModal,
|
||||
deletePolicy,
|
||||
serializeRules,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
|
||||
638
src/contexts/ReverseProxiesProvider.tsx
Normal file
638
src/contexts/ReverseProxiesProvider.tsx
Normal file
@@ -0,0 +1,638 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ReverseProxy,
|
||||
ReverseProxyDomain,
|
||||
ReverseProxyFlatTarget,
|
||||
ReverseProxyTarget,
|
||||
ReverseProxyTargetProtocol,
|
||||
ReverseProxyTargetType,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
|
||||
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
|
||||
|
||||
type ReverseProxiesContextValue = {
|
||||
reverseProxies: ReverseProxy[] | undefined;
|
||||
resources: NetworkResource[] | undefined;
|
||||
peers: Peer[] | undefined;
|
||||
isLoading: boolean;
|
||||
openModal: (options?: OpenModalOptions) => void;
|
||||
openTargetModal: (options: OpenTargetModalOptions) => void;
|
||||
handleCreateOrUpdateProxy: (options: HandleCreateOrUpdateOptions) => void;
|
||||
resolveDestination: (target: ReverseProxyTarget) => string;
|
||||
handleToggle: (proxy: ReverseProxy) => Promise<void>;
|
||||
handleDelete: (proxy: ReverseProxy) => Promise<void>;
|
||||
handleDeleteTarget: (
|
||||
proxy: ReverseProxy,
|
||||
target: ReverseProxyTarget,
|
||||
) => Promise<void>;
|
||||
handleToggleTarget: (
|
||||
proxy: ReverseProxy,
|
||||
target: ReverseProxyTarget,
|
||||
) => Promise<void>;
|
||||
domains: ReverseProxyDomain[] | undefined;
|
||||
isLoadingDomains: boolean;
|
||||
validateDomain: (domainId: string) => Promise<void>;
|
||||
deleteDomain: (domain: ReverseProxyDomain) => Promise<void>;
|
||||
createDomain: (
|
||||
domain: string,
|
||||
targetCluster: string,
|
||||
) => Promise<ReverseProxyDomain>;
|
||||
};
|
||||
|
||||
type OpenModalOptions = {
|
||||
proxy?: ReverseProxy;
|
||||
initialTab?: string;
|
||||
initialPeer?: Peer;
|
||||
initialNetwork?: Network;
|
||||
initialResource?: NetworkResource;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
type OpenTargetModalOptions = {
|
||||
proxy: ReverseProxy;
|
||||
target?: ReverseProxyTarget;
|
||||
};
|
||||
|
||||
type HandleCreateOrUpdateOptions = {
|
||||
data: Partial<ReverseProxy>;
|
||||
proxyId?: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
initialPeer?: Peer;
|
||||
initialNetwork?: Network;
|
||||
};
|
||||
|
||||
const ReverseProxiesContext = createContext<ReverseProxiesContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export default function ReverseProxiesProvider({
|
||||
children,
|
||||
initialPeer,
|
||||
initialNetwork,
|
||||
}: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
// Reverse Proxies
|
||||
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
|
||||
"/reverse-proxies/services",
|
||||
);
|
||||
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
|
||||
|
||||
// Peers & Resources for resolving target destinations
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { data: resources } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
|
||||
const resolveDestination = useCallback(
|
||||
(target: ReverseProxyTarget) => {
|
||||
if (target.host === "unknown") return target.host;
|
||||
const host = resolveTargetHost(target, peers, resources);
|
||||
return formatTargetDestination(target, host);
|
||||
},
|
||||
[peers, resources],
|
||||
);
|
||||
|
||||
const reverseProxies = useMemo(() => {
|
||||
return rawReverseProxies?.map((proxy) => ({
|
||||
...proxy,
|
||||
targets: proxy.targets.map((target) => ({
|
||||
...target,
|
||||
destination: resolveDestination(target),
|
||||
})),
|
||||
}));
|
||||
}, [rawReverseProxies, resolveDestination]);
|
||||
|
||||
// Domains
|
||||
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
|
||||
ReverseProxyDomain[]
|
||||
>("/reverse-proxies/domains");
|
||||
const domainRequest = useApiCall<ReverseProxyDomain>(
|
||||
"/reverse-proxies/domains",
|
||||
true,
|
||||
);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
|
||||
const [initialTab, setInitialTab] = useState<string | undefined>();
|
||||
const [modalInitialPeer, setModalInitialPeer] = useState<Peer | undefined>();
|
||||
const [modalInitialNetwork, setModalInitialNetwork] = useState<
|
||||
Network | undefined
|
||||
>();
|
||||
const [targetModalOpen, setTargetModalOpen] = useState(false);
|
||||
const [targetModalProxy, setTargetModalProxy] = useState<
|
||||
ReverseProxy | undefined
|
||||
>();
|
||||
const [editingTarget, setEditingTarget] = useState<
|
||||
ReverseProxyTarget | undefined
|
||||
>();
|
||||
const [modalInitialResource, setModalInitialResource] = useState<
|
||||
NetworkResource | undefined
|
||||
>();
|
||||
const onSuccessRef = React.useRef<(() => void) | undefined>(undefined);
|
||||
const openModal = useCallback(
|
||||
(options?: OpenModalOptions) => {
|
||||
setCurrentProxy(options?.proxy);
|
||||
setInitialTab(options?.initialTab);
|
||||
setModalInitialPeer(options?.initialPeer ?? initialPeer);
|
||||
setModalInitialNetwork(options?.initialNetwork ?? initialNetwork);
|
||||
setModalInitialResource(options?.initialResource);
|
||||
onSuccessRef.current = options?.onSuccess;
|
||||
setModalOpen(true);
|
||||
},
|
||||
[initialPeer, initialNetwork],
|
||||
);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModalOpen(false);
|
||||
setCurrentProxy(undefined);
|
||||
setInitialTab(undefined);
|
||||
setModalInitialPeer(undefined);
|
||||
setModalInitialNetwork(undefined);
|
||||
setModalInitialResource(undefined);
|
||||
onSuccessRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
const openTargetModal = useCallback((options: OpenTargetModalOptions) => {
|
||||
setTargetModalProxy(options.proxy);
|
||||
setEditingTarget(options.target);
|
||||
setTargetModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeTargetModal = useCallback(() => {
|
||||
setTargetModalOpen(false);
|
||||
setTargetModalProxy(undefined);
|
||||
setEditingTarget(undefined);
|
||||
}, []);
|
||||
|
||||
const handleSaveTarget = useCallback(
|
||||
async (target: ReverseProxyTarget) => {
|
||||
if (!targetModalProxy) return;
|
||||
|
||||
let updatedTargets: ReverseProxyTarget[];
|
||||
const isEditing = !!editingTarget;
|
||||
const proxyId = targetModalProxy.id;
|
||||
|
||||
if (isEditing) {
|
||||
// Update existing target - match by index against the original target
|
||||
const targetIndex = targetModalProxy.targets.indexOf(editingTarget);
|
||||
updatedTargets = targetModalProxy.targets.map((t, i) => {
|
||||
return i === targetIndex ? target : t;
|
||||
});
|
||||
} else {
|
||||
// Add new target
|
||||
updatedTargets = [...(targetModalProxy.targets || []), target];
|
||||
}
|
||||
|
||||
notify({
|
||||
title: targetModalProxy.domain,
|
||||
description: isEditing
|
||||
? "Target updated successfully"
|
||||
: "Target added successfully",
|
||||
promise: request
|
||||
.put(
|
||||
{ ...targetModalProxy, targets: sanitizeTargets(updatedTargets) },
|
||||
`/${targetModalProxy.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/reverse-proxies/services");
|
||||
// After adding a new target, scroll to the row and open the accordion
|
||||
if (!isEditing) {
|
||||
setTimeout(() => {
|
||||
const row = document.querySelector<HTMLElement>(
|
||||
`[data-row-id="${proxyId}"]`,
|
||||
);
|
||||
if (row?.getAttribute("data-accordion") === "closed") {
|
||||
row?.click();
|
||||
}
|
||||
row?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 200);
|
||||
}
|
||||
}),
|
||||
loadingMessage: isEditing ? "Updating target..." : "Adding target...",
|
||||
});
|
||||
closeTargetModal();
|
||||
},
|
||||
[targetModalProxy, editingTarget, request, mutate, closeTargetModal],
|
||||
);
|
||||
|
||||
const handleCreateOrUpdateProxy = useCallback(
|
||||
({ data, proxyId, onSuccess }: HandleCreateOrUpdateOptions) => {
|
||||
const sanitizedData = {
|
||||
...data,
|
||||
targets: data.targets ? sanitizeTargets(data.targets) : undefined,
|
||||
};
|
||||
const isCreating = !proxyId;
|
||||
const promise = isCreating
|
||||
? request.post(sanitizedData)
|
||||
: request.put(sanitizedData, `/${proxyId}`);
|
||||
|
||||
notify({
|
||||
title: data.domain || "",
|
||||
description: isCreating
|
||||
? "Service was successfully created"
|
||||
: "Service was successfully updated",
|
||||
promise: promise.then((result) => {
|
||||
mutate("/reverse-proxies/services");
|
||||
onSuccess?.();
|
||||
if (isCreating && result?.id) {
|
||||
setTimeout(() => {
|
||||
const row = document.querySelector<HTMLElement>(
|
||||
`[data-row-id="${result.id}"]`,
|
||||
);
|
||||
if (row?.getAttribute("data-accordion") === "closed") {
|
||||
row?.click();
|
||||
}
|
||||
row?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 200);
|
||||
}
|
||||
}),
|
||||
loadingMessage: isCreating
|
||||
? "Creating service..."
|
||||
: "Updating service...",
|
||||
});
|
||||
},
|
||||
[request, mutate],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (proxy: ReverseProxy) => {
|
||||
const newEnabled = !proxy.enabled;
|
||||
notify({
|
||||
title: proxy.domain,
|
||||
description: `Reverse proxy ${newEnabled ? "enabled" : "disabled"}`,
|
||||
promise: request
|
||||
.put(
|
||||
{
|
||||
...proxy,
|
||||
enabled: newEnabled,
|
||||
targets: sanitizeTargets(proxy.targets),
|
||||
},
|
||||
`/${proxy.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/reverse-proxies/services");
|
||||
}),
|
||||
loadingMessage: `${
|
||||
newEnabled ? "Enabling" : "Disabling"
|
||||
} reverse proxy...`,
|
||||
});
|
||||
},
|
||||
[mutate, request],
|
||||
);
|
||||
|
||||
const handleToggleTarget = useCallback(
|
||||
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
|
||||
const newEnabled = !target.enabled;
|
||||
const targetIndex = proxy.targets.indexOf(target);
|
||||
const updatedTargets = proxy.targets.map((t, i) => {
|
||||
return i === targetIndex ? { ...t, enabled: newEnabled } : t;
|
||||
});
|
||||
notify({
|
||||
title: proxy.domain,
|
||||
description: `Target ${newEnabled ? "enabled" : "disabled"}`,
|
||||
promise: request
|
||||
.put(
|
||||
{ ...proxy, targets: sanitizeTargets(updatedTargets) },
|
||||
`/${proxy.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/reverse-proxies/services");
|
||||
}),
|
||||
loadingMessage: `${newEnabled ? "Enabling" : "Disabling"} target...`,
|
||||
});
|
||||
},
|
||||
[mutate, request],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (proxy: ReverseProxy) => {
|
||||
const choice = await confirm({
|
||||
title: `Delete '${proxy.domain}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this reverse proxy? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
notify({
|
||||
title: proxy.domain,
|
||||
description: "Reverse proxy was successfully deleted",
|
||||
promise: request.del({}, `/${proxy.id}`).then(() => {
|
||||
mutate("/reverse-proxies/services");
|
||||
}),
|
||||
loadingMessage: "Deleting reverse proxy...",
|
||||
});
|
||||
},
|
||||
[confirm, request, mutate],
|
||||
);
|
||||
|
||||
const handleDeleteTarget = useCallback(
|
||||
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
|
||||
const isOnlyTarget = proxy.targets.length <= 1;
|
||||
|
||||
const choice = await confirm({
|
||||
title: isOnlyTarget ? `Delete '${proxy.domain}'?` : `Delete target?`,
|
||||
description: isOnlyTarget
|
||||
? "This is the only target for this service. Deleting it will remove the entire service. This action cannot be undone."
|
||||
: "Are you sure you want to delete this target? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
if (isOnlyTarget) {
|
||||
notify({
|
||||
title: proxy.domain,
|
||||
description: "Service was successfully deleted",
|
||||
promise: request.del({}, `/${proxy.id}`).then(() => {
|
||||
mutate("/reverse-proxies/services");
|
||||
}),
|
||||
loadingMessage: "Deleting service...",
|
||||
});
|
||||
} else {
|
||||
const targetIndex = proxy.targets.indexOf(target);
|
||||
const updatedTargets = proxy.targets.filter(
|
||||
(_, i) => i !== targetIndex,
|
||||
);
|
||||
|
||||
notify({
|
||||
title: proxy.domain,
|
||||
description: "Target was successfully deleted",
|
||||
promise: request
|
||||
.put(
|
||||
{ ...proxy, targets: sanitizeTargets(updatedTargets) },
|
||||
`/${proxy.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/reverse-proxies/services");
|
||||
}),
|
||||
loadingMessage: "Deleting target...",
|
||||
});
|
||||
}
|
||||
},
|
||||
[confirm, request, mutate],
|
||||
);
|
||||
|
||||
const createDomain = useCallback(
|
||||
async (
|
||||
domain: string,
|
||||
targetCluster: string,
|
||||
): Promise<ReverseProxyDomain> => {
|
||||
const promise = domainRequest
|
||||
.post({
|
||||
domain,
|
||||
target_cluster: targetCluster,
|
||||
})
|
||||
.then((d) => {
|
||||
mutate("/reverse-proxies/domains");
|
||||
return d;
|
||||
});
|
||||
notify({
|
||||
title: "Add Custom Domain",
|
||||
description: "Domain successfully added",
|
||||
promise,
|
||||
loadingMessage: "Adding domain...",
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
[domainRequest, mutate],
|
||||
);
|
||||
|
||||
const validateDomain = useCallback(
|
||||
async (domainId: string) => {
|
||||
// Delay refetch to allow the server to propagate the validation result
|
||||
const DOMAIN_VALIDATION_REFETCH_DELAY_MS = 2000;
|
||||
notify({
|
||||
title: "Domain Validation",
|
||||
description: "Domain validation started",
|
||||
promise: domainRequest.get(`/${domainId}/validate`).then(() => {
|
||||
setTimeout(() => {
|
||||
mutate("/reverse-proxies/domains");
|
||||
}, DOMAIN_VALIDATION_REFETCH_DELAY_MS);
|
||||
}),
|
||||
loadingMessage: "Validating domain...",
|
||||
});
|
||||
},
|
||||
[domainRequest, mutate],
|
||||
);
|
||||
|
||||
const deleteDomain = useCallback(
|
||||
async (domain: ReverseProxyDomain) => {
|
||||
const choice = await confirm({
|
||||
title: `Delete '${domain.domain}'?`,
|
||||
description:
|
||||
"Are you sure you want to delete this domain? This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
notify({
|
||||
title: domain.domain,
|
||||
description: "Domain was successfully deleted",
|
||||
promise: domainRequest.del({}, `/${domain.id}`).then(() => {
|
||||
mutate("/reverse-proxies/domains");
|
||||
}),
|
||||
loadingMessage: "Deleting domain...",
|
||||
});
|
||||
},
|
||||
[confirm, domainRequest, mutate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReverseProxiesContext.Provider
|
||||
value={{
|
||||
reverseProxies,
|
||||
resources,
|
||||
peers,
|
||||
isLoading,
|
||||
openModal,
|
||||
openTargetModal,
|
||||
handleCreateOrUpdateProxy,
|
||||
handleToggle,
|
||||
handleToggleTarget,
|
||||
handleDelete,
|
||||
handleDeleteTarget,
|
||||
resolveDestination,
|
||||
domains,
|
||||
isLoadingDomains,
|
||||
createDomain,
|
||||
validateDomain,
|
||||
deleteDomain,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{modalOpen && (
|
||||
<ReverseProxyModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeModal();
|
||||
}}
|
||||
reverseProxy={currentProxy}
|
||||
domains={domains}
|
||||
initialTab={initialTab}
|
||||
initialPeer={modalInitialPeer}
|
||||
initialNetwork={modalInitialNetwork}
|
||||
initialResource={modalInitialResource}
|
||||
initialSubdomain={modalInitialResource?.name}
|
||||
onSuccess={onSuccessRef.current}
|
||||
/>
|
||||
)}
|
||||
{targetModalOpen && targetModalProxy && (
|
||||
<ReverseProxyTargetModal
|
||||
key={targetModalOpen ? 1 : 0}
|
||||
open={targetModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeTargetModal();
|
||||
}}
|
||||
onSave={handleSaveTarget}
|
||||
currentTarget={editingTarget}
|
||||
reverseProxy={targetModalProxy}
|
||||
initialPeer={initialPeer}
|
||||
initialNetwork={initialNetwork}
|
||||
/>
|
||||
)}
|
||||
</ReverseProxiesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useReverseProxies = () => {
|
||||
const context = useContext(ReverseProxiesContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useReverseProxies must be used within a ReverseProxiesProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
type FlattenReverseProxiesParams = {
|
||||
reverseProxies: ReverseProxy[] | undefined;
|
||||
peer?: Peer;
|
||||
network?: Network;
|
||||
};
|
||||
|
||||
export function flattenReverseProxies({
|
||||
reverseProxies,
|
||||
peer,
|
||||
network,
|
||||
}: FlattenReverseProxiesParams): ReverseProxyFlatTarget[] {
|
||||
if (!reverseProxies) return [];
|
||||
|
||||
const flattened: ReverseProxyFlatTarget[] = [];
|
||||
|
||||
reverseProxies.forEach((proxy) => {
|
||||
proxy.targets.forEach((target) => {
|
||||
// Filter by peer if provided
|
||||
if (peer) {
|
||||
if (
|
||||
target.target_type !== ReverseProxyTargetType.PEER ||
|
||||
target.target_id !== peer.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by network if provided (check if target resource belongs to network)
|
||||
if (network && !peer) {
|
||||
if (isResourceTargetType(target.target_type)) {
|
||||
const isResourceInNetwork = network.resources?.includes(
|
||||
target.target_id || "",
|
||||
);
|
||||
if (!isResourceInNetwork) return;
|
||||
} else {
|
||||
// For peer targets in network context, skip them
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
flattened.push({
|
||||
...target,
|
||||
proxy,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
export function sanitizeTargets(
|
||||
targets: ReverseProxyTarget[],
|
||||
): ReverseProxyTarget[] {
|
||||
return targets.map((t) => {
|
||||
const { destination: _, ...target } = t;
|
||||
if (t.target_type === ReverseProxyTargetType.SUBNET)
|
||||
return target as ReverseProxyTarget;
|
||||
const { host: __, ...rest } = target;
|
||||
return rest as ReverseProxyTarget;
|
||||
});
|
||||
}
|
||||
|
||||
export function isResourceTargetType(type: ReverseProxyTargetType): boolean {
|
||||
return (
|
||||
type === ReverseProxyTargetType.HOST ||
|
||||
type === ReverseProxyTargetType.DOMAIN ||
|
||||
type === ReverseProxyTargetType.SUBNET
|
||||
);
|
||||
}
|
||||
|
||||
function formatTargetDestination(
|
||||
target: ReverseProxyTarget,
|
||||
resolvedHost?: string,
|
||||
): string {
|
||||
const host = target.host || resolvedHost || "localhost";
|
||||
const isDefault =
|
||||
(target.protocol === "http" && target.port === 80) ||
|
||||
(target.protocol === "https" && target.port === 443) ||
|
||||
target.port === 0;
|
||||
return isDefault
|
||||
? `${target.protocol}://${host}`
|
||||
: `${target.protocol}://${host}:${target.port}`;
|
||||
}
|
||||
|
||||
export function defaultPortForProtocol(
|
||||
protocol: ReverseProxyTargetProtocol,
|
||||
): number {
|
||||
return protocol === ReverseProxyTargetProtocol.HTTPS ? 443 : 80;
|
||||
}
|
||||
|
||||
function resolveTargetHost(
|
||||
target: ReverseProxyTarget,
|
||||
peers?: Peer[],
|
||||
resources?: NetworkResource[],
|
||||
): string {
|
||||
if (target.host) return "";
|
||||
if (target.target_type === ReverseProxyTargetType.PEER) {
|
||||
return peers?.find((p) => p.id === target.target_id)?.ip ?? "";
|
||||
}
|
||||
if (isResourceTargetType(target.target_type)) {
|
||||
const address = resources?.find((r) => r.id === target.target_id)?.address;
|
||||
if (!address) return "";
|
||||
return address.includes("/") ? address.split("/")[0] : address;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
239
src/contexts/ServerPaginationProvider.tsx
Normal file
239
src/contexts/ServerPaginationProvider.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Pagination } from "@/interfaces/Pagination";
|
||||
|
||||
type ServerPaginationContextValue<T = unknown> = {
|
||||
data?: T;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
mutate: () => Promise<unknown>;
|
||||
|
||||
pagination: { pageIndex: number; pageSize: number };
|
||||
pageCount: number;
|
||||
totalRecords?: number;
|
||||
onPaginationChange: (pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) => void;
|
||||
|
||||
globalFilter: string;
|
||||
onGlobalFilterChange: (value: string) => void;
|
||||
setFilter: (key: string, value: string | undefined) => void;
|
||||
getFilter: (key: string) => string | undefined;
|
||||
setSort: (name: string, direction: "asc" | "desc") => void;
|
||||
hasActiveFilters: boolean;
|
||||
resetFilters: () => void;
|
||||
onFilterReset: () => void;
|
||||
|
||||
hasServerSideFilters: boolean;
|
||||
serverSidePagination: true;
|
||||
manualPagination: true;
|
||||
manualFiltering: true;
|
||||
keepStateInLocalStorage: false;
|
||||
};
|
||||
|
||||
const ServerPaginationContext =
|
||||
createContext<ServerPaginationContextValue | null>(null);
|
||||
|
||||
type ProviderProps = {
|
||||
url: string;
|
||||
defaultPageSize?: number;
|
||||
defaultFilters?: Record<string, string>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function ServerPaginationProvider({
|
||||
url,
|
||||
defaultPageSize = 50,
|
||||
defaultFilters,
|
||||
children,
|
||||
}: Readonly<ProviderProps>) {
|
||||
const { mutate: swrMutate } = useSWRConfig();
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filters, setFilters] = useState<Record<string, string | undefined>>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
|
||||
const buildUrl = useCallback(
|
||||
(
|
||||
p: number,
|
||||
ps: number,
|
||||
s: string,
|
||||
f: Record<string, string | undefined>,
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(p));
|
||||
params.set("page_size", String(ps));
|
||||
if (s) params.set("search", s.toLowerCase());
|
||||
Object.entries(f).forEach(([key, value]) => {
|
||||
if (value !== undefined) params.set(key, value);
|
||||
});
|
||||
return `${url}?${params.toString()}`;
|
||||
},
|
||||
[url],
|
||||
);
|
||||
|
||||
const apiUrl = buildUrl(page, pageSize, search, filters);
|
||||
|
||||
const {
|
||||
data: response,
|
||||
isLoading,
|
||||
isValidating,
|
||||
} = useFetchApi<Pagination<unknown>>(apiUrl);
|
||||
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const previousResponse = useRef<Pagination<unknown> | undefined>(undefined);
|
||||
if (response?.data) {
|
||||
hasLoadedOnce.current = true;
|
||||
previousResponse.current = response;
|
||||
}
|
||||
|
||||
const activeResponse = response ?? previousResponse.current;
|
||||
const totalPages = activeResponse?.total_pages ?? 0;
|
||||
const hasNextPage = page < totalPages;
|
||||
|
||||
// Prefetch next page
|
||||
useFetchApi<Pagination<unknown>>(
|
||||
hasNextPage ? buildUrl(page + 1, pageSize, search, filters) : apiUrl,
|
||||
false,
|
||||
false,
|
||||
hasNextPage,
|
||||
);
|
||||
|
||||
const onPaginationChange = useCallback(
|
||||
(pagination: { pageIndex: number; pageSize: number }) => {
|
||||
if (pagination.pageSize !== pageSize) {
|
||||
setPage(1);
|
||||
setPageSize(pagination.pageSize);
|
||||
} else {
|
||||
setPage(pagination.pageIndex + 1);
|
||||
}
|
||||
},
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
const onGlobalFilterChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const filterTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const pendingFilters = useRef<Record<string, string | undefined>>({});
|
||||
useEffect(() => () => clearTimeout(filterTimeout.current), []);
|
||||
|
||||
const setFilter = useCallback((key: string, value: string | undefined) => {
|
||||
pendingFilters.current[key] = value;
|
||||
clearTimeout(filterTimeout.current);
|
||||
filterTimeout.current = setTimeout(() => {
|
||||
const pending = pendingFilters.current;
|
||||
pendingFilters.current = {};
|
||||
setFilters((prev) => ({ ...prev, ...pending }));
|
||||
setPage(1);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const getFilter = useCallback((key: string) => filters[key], [filters]);
|
||||
|
||||
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort_by: name,
|
||||
sort_order: direction,
|
||||
}));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
search !== "" ||
|
||||
Object.entries(filters).some(
|
||||
([key, value]) => value !== (defaultFilters ?? {})[key],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearch("");
|
||||
setFilters(defaultFilters ?? {});
|
||||
setPage(1);
|
||||
}, [defaultFilters]);
|
||||
|
||||
const mutate = useCallback(() => {
|
||||
return swrMutate(apiUrl);
|
||||
}, [swrMutate, apiUrl]);
|
||||
|
||||
const value = useMemo<ServerPaginationContextValue>(
|
||||
() => ({
|
||||
data: activeResponse?.data,
|
||||
isLoading: isLoading && !hasLoadedOnce.current,
|
||||
isFetching: isValidating,
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
pagination: { pageIndex: page - 1, pageSize },
|
||||
pageCount: totalPages,
|
||||
totalRecords: activeResponse?.total_records,
|
||||
onPaginationChange,
|
||||
manualPagination: true,
|
||||
serverSidePagination: true,
|
||||
manualFiltering: true,
|
||||
keepStateInLocalStorage: false,
|
||||
globalFilter: search,
|
||||
onGlobalFilterChange,
|
||||
onFilterReset: resetFilters,
|
||||
hasServerSideFilters: hasActiveFilters,
|
||||
}),
|
||||
[
|
||||
activeResponse?.data,
|
||||
activeResponse?.total_records,
|
||||
isLoading,
|
||||
isValidating,
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
onPaginationChange,
|
||||
search,
|
||||
onGlobalFilterChange,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ServerPaginationContext.Provider value={value}>
|
||||
{children}
|
||||
</ServerPaginationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useServerPagination<T>() {
|
||||
const context = useContext(ServerPaginationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useServerPagination must be used within a ServerPaginationProvider",
|
||||
);
|
||||
}
|
||||
return context as ServerPaginationContextValue<T>;
|
||||
}
|
||||
|
||||
export function useOptionalServerPagination<T>() {
|
||||
const context = useContext(ServerPaginationContext);
|
||||
return context as ServerPaginationContextValue<T> | null;
|
||||
}
|
||||
@@ -28,14 +28,30 @@ const UserProfileContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi<
|
||||
User[]
|
||||
>("/users?service_user=true");
|
||||
|
||||
const refresh = () => {
|
||||
mutate().then();
|
||||
mutateServiceUsers().then();
|
||||
};
|
||||
|
||||
const allUsers = useMemo(() => {
|
||||
return [...(users ?? []), ...(serviceUsers ?? [])];
|
||||
}, [users, serviceUsers]);
|
||||
|
||||
return (
|
||||
<UsersContext.Provider value={{ users, refresh, isLoading }}>
|
||||
<UsersContext.Provider
|
||||
value={{
|
||||
users: allUsers,
|
||||
refresh,
|
||||
isLoading: isLoading || isLoadingServiceUsers,
|
||||
}}
|
||||
>
|
||||
<UserProfileProvider>{children}</UserProfileProvider>
|
||||
</UsersContext.Provider>
|
||||
);
|
||||
|
||||
38
src/hooks/useUrlTab.ts
Normal file
38
src/hooks/useUrlTab.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function useUrlTab(
|
||||
validTabs: string[],
|
||||
defaultTab: string,
|
||||
): [string, (value: string) => void] {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getTab = useCallback(
|
||||
(params: URLSearchParams) => {
|
||||
const tabParam = params.get("tab");
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
},
|
||||
[validTabs, defaultTab],
|
||||
);
|
||||
|
||||
const [tab, setTabState] = useState(() => getTab(searchParams));
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = getTab(searchParams);
|
||||
setTabState(newTab);
|
||||
}, [searchParams, getTab]);
|
||||
|
||||
const setTab = useCallback(
|
||||
(value: string) => {
|
||||
const nextTab = validTabs.includes(value) ? value : defaultTab;
|
||||
setTabState(nextTab);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("tab", nextTab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
},
|
||||
[validTabs, defaultTab],
|
||||
);
|
||||
|
||||
return [tab, setTab];
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface Account {
|
||||
user_approval_required: boolean;
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_expose_enabled?: boolean;
|
||||
peer_expose_groups?: string[];
|
||||
peer_login_expiration: number;
|
||||
peer_inactivity_expiration_enabled: boolean;
|
||||
peer_inactivity_expiration: number;
|
||||
@@ -24,6 +26,7 @@ export interface Account {
|
||||
lazy_connection_enabled: boolean;
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
auto_update_always: boolean;
|
||||
local_auth_disabled?: boolean;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface NetworkRouter {
|
||||
metric: number;
|
||||
masquerade: boolean;
|
||||
enabled: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface NetworkResource {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface Permissions {
|
||||
|
||||
proxy: Permission;
|
||||
proxy_configuration: Permission;
|
||||
|
||||
services: Permission;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
205
src/interfaces/ReverseProxy.ts
Normal file
205
src/interfaces/ReverseProxy.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
export enum ServiceMode {
|
||||
HTTP = "http",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
TLS = "tls",
|
||||
}
|
||||
|
||||
export interface ReverseProxy {
|
||||
id?: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
mode?: ServiceMode;
|
||||
listen_port?: number;
|
||||
port_auto_assigned?: boolean;
|
||||
proxy_cluster?: string;
|
||||
targets: ReverseProxyTarget[];
|
||||
enabled: boolean;
|
||||
pass_host_header?: boolean;
|
||||
rewrite_redirects?: boolean;
|
||||
auth?: ReverseProxyAuth;
|
||||
access_restrictions?: AccessRestrictions;
|
||||
meta?: ReverseProxyMeta;
|
||||
}
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
}
|
||||
|
||||
export interface ReverseProxyMeta {
|
||||
created_at: string;
|
||||
status: ReverseProxyStatus;
|
||||
certificate_issued_at?: string;
|
||||
}
|
||||
|
||||
export enum ReverseProxyStatus {
|
||||
PENDING = "pending",
|
||||
ACTIVE = "active",
|
||||
TUNNEL_NOT_CREATED = "tunnel_not_created",
|
||||
CERTIFICATE_PENDING = "certificate_pending",
|
||||
CERTIFICATE_FAILED = "certificate_failed",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export type ServiceTargetOptionsPathRewrite = "preserve";
|
||||
|
||||
export interface ServiceTargetOptions {
|
||||
skip_tls_verify?: boolean;
|
||||
request_timeout?: string;
|
||||
session_idle_timeout?: string;
|
||||
path_rewrite?: ServiceTargetOptionsPathRewrite;
|
||||
custom_headers?: Record<string, string>;
|
||||
proxy_protocol?: boolean;
|
||||
}
|
||||
|
||||
export interface ReverseProxyTarget {
|
||||
target_id?: string;
|
||||
target_type: ReverseProxyTargetType;
|
||||
path?: string;
|
||||
protocol: ReverseProxyTargetProtocol;
|
||||
host?: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
access_local?: boolean;
|
||||
options?: ServiceTargetOptions;
|
||||
// Frontend
|
||||
destination?: string;
|
||||
}
|
||||
|
||||
export interface ReverseProxyAuth {
|
||||
password_auth?: {
|
||||
enabled: boolean;
|
||||
password: string;
|
||||
};
|
||||
pin_auth?: {
|
||||
enabled: boolean;
|
||||
pin: string;
|
||||
};
|
||||
bearer_auth?: {
|
||||
enabled: boolean;
|
||||
distribution_groups: string[];
|
||||
};
|
||||
link_auth?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
header_auths?: HeaderAuthConfig[];
|
||||
}
|
||||
|
||||
export interface HeaderAuthConfig {
|
||||
enabled: boolean;
|
||||
header: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ReverseProxyDomain {
|
||||
id: string;
|
||||
domain: string;
|
||||
validated: boolean;
|
||||
type: ReverseProxyDomainType;
|
||||
target_cluster?: string;
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
}
|
||||
|
||||
export enum ReverseProxyDomainType {
|
||||
FREE = "free",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
export enum ReverseProxyTargetType {
|
||||
PEER = "peer",
|
||||
HOST = "host",
|
||||
DOMAIN = "domain",
|
||||
SUBNET = "subnet",
|
||||
}
|
||||
|
||||
export enum ReverseProxyTargetProtocol {
|
||||
HTTP = "http",
|
||||
HTTPS = "https",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
}
|
||||
|
||||
export enum EventProtocol {
|
||||
HTTP = "http",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
TLS = "tls",
|
||||
}
|
||||
|
||||
export interface ReverseProxyEvent {
|
||||
id: string;
|
||||
service_id: string;
|
||||
timestamp: string;
|
||||
method: string;
|
||||
host: string;
|
||||
path: string;
|
||||
duration_ms: number;
|
||||
status_code: number;
|
||||
source_ip: string;
|
||||
reason?: string;
|
||||
user_id?: string;
|
||||
auth_method_used?: string;
|
||||
country_code?: string;
|
||||
city_name?: string;
|
||||
subdivision_code?: string;
|
||||
bytes_upload: number;
|
||||
bytes_download: number;
|
||||
protocol?: EventProtocol;
|
||||
}
|
||||
|
||||
export function isL4Event(event: ReverseProxyEvent): boolean {
|
||||
return (
|
||||
event.protocol === EventProtocol.TCP ||
|
||||
event.protocol === EventProtocol.UDP ||
|
||||
event.protocol === EventProtocol.TLS
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
|
||||
proxy: ReverseProxy;
|
||||
}
|
||||
|
||||
export function isL4Mode(mode?: ServiceMode): boolean {
|
||||
return (
|
||||
mode === ServiceMode.TCP ||
|
||||
mode === ServiceMode.UDP ||
|
||||
mode === ServiceMode.TLS
|
||||
);
|
||||
}
|
||||
|
||||
export const REVERSE_PROXY_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy";
|
||||
|
||||
export const REVERSE_PROXY_SERVICES_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#services";
|
||||
|
||||
export const REVERSE_PROXY_TARGETS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#targets";
|
||||
|
||||
export const REVERSE_PROXY_AUTHENTICATION_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/authentication";
|
||||
|
||||
export const REVERSE_PROXY_SETTINGS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#step-4-configure-advanced-settings";
|
||||
|
||||
export const REVERSE_PROXY_CLUSTERS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#self-hosted-proxy-setup";
|
||||
|
||||
export const REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/custom-domains";
|
||||
|
||||
export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/custom-domains#validating-a-custom-domain";
|
||||
|
||||
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
|
||||
|
||||
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy";
|
||||
|
||||
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
|
||||
@@ -9,7 +9,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import React, { Suspense } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Toaster } from "sonner";
|
||||
import OIDCProvider from "@/auth/OIDCProvider";
|
||||
import FullScreenLoading from "@/components/ui/FullScreenLoading";
|
||||
import AnalyticsProvider, {
|
||||
@@ -59,10 +59,13 @@ export default function AppLayout({
|
||||
</GlobalThemeProvider>
|
||||
</DialogProvider>
|
||||
<Toaster
|
||||
position={"top-center"}
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
}}
|
||||
position="top-center"
|
||||
duration={3000}
|
||||
toastOptions={{ unstyled: true }}
|
||||
style={{ "--width": "28rem" } as React.CSSProperties}
|
||||
gap={0}
|
||||
visibleToasts={5}
|
||||
offset="12px"
|
||||
/>
|
||||
<NavigationEvents />
|
||||
<DisableDarkReader />
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"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";
|
||||
@@ -20,6 +17,10 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { headerHeight } from "@/layouts/Header";
|
||||
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import * as React from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
|
||||
type Props = {
|
||||
fullWidth?: boolean;
|
||||
@@ -131,6 +132,41 @@ export default function Navigation({
|
||||
|
||||
<NetworkNavigation />
|
||||
|
||||
<SidebarItem
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
labelClassName={"pr-0"}
|
||||
label={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
Reverse Proxy
|
||||
<SmallBadge
|
||||
text={"Beta"}
|
||||
variant={"sky"}
|
||||
className={"text-[8px] leading-none py-[3px] px-[5px]"}
|
||||
textClassName={"top-0"}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
href={"/reverse-proxy"}
|
||||
collapsible
|
||||
exactPathMatch={false}
|
||||
visible={permission?.services?.read}
|
||||
>
|
||||
<SidebarItem
|
||||
label="Services"
|
||||
isChild
|
||||
href={"/reverse-proxy/services"}
|
||||
exactPathMatch={true}
|
||||
visible={permission?.services?.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Custom Domains"
|
||||
isChild
|
||||
href={"/reverse-proxy/custom-domains"}
|
||||
exactPathMatch={true}
|
||||
visible={permission?.services?.read}
|
||||
/>
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
icon={<DNSIcon />}
|
||||
label="DNS"
|
||||
@@ -176,13 +212,7 @@ export default function Navigation({
|
||||
visible={permission.users.read}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
icon={<ActivityIcon />}
|
||||
label="Activity"
|
||||
href={"/events/audit"}
|
||||
exactPathMatch={true}
|
||||
visible={permission.events.read}
|
||||
/>
|
||||
<ActivityNavigationItem />
|
||||
</SidebarItemGroup>
|
||||
|
||||
<SidebarItemGroup>
|
||||
@@ -225,3 +255,31 @@ export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ActivityNavigationItem = () => {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
icon={<ActivityIcon />}
|
||||
label="Activity"
|
||||
collapsible
|
||||
visible={permission.events.read}
|
||||
>
|
||||
<SidebarItem
|
||||
label="Audit Events"
|
||||
href={"/events/audit"}
|
||||
isChild
|
||||
exactPathMatch={true}
|
||||
visible={permission.events.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Proxy Events"
|
||||
isChild
|
||||
href={"/events/proxy"}
|
||||
exactPathMatch={true}
|
||||
visible={permission.events.read}
|
||||
/>
|
||||
</SidebarItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ import React, { useMemo, useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { useAccessControl } from "@/modules/access-control/useAccessControl";
|
||||
@@ -54,6 +55,7 @@ import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheck
|
||||
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
|
||||
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -124,6 +126,8 @@ type ModalProps = {
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
initialTab?: string;
|
||||
disableDestinationSelector?: boolean;
|
||||
additionalResources?: NetworkResource[];
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -140,6 +144,8 @@ export function AccessControlModalContent({
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
initialTab,
|
||||
disableDestinationSelector = false,
|
||||
additionalResources,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
const { users } = useUsers();
|
||||
@@ -293,7 +299,25 @@ export function AccessControlModalContent({
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
|
||||
<SelectItem
|
||||
value="netbird-ssh"
|
||||
extra={
|
||||
<HelpTooltip
|
||||
triggerClassName={"ml-[0.01rem]"}
|
||||
align={"center"}
|
||||
side={"right"}
|
||||
content={
|
||||
<>
|
||||
Select NetBird SSH for SSH-specific policies with
|
||||
fine-grained access control, or use TCP with port 22
|
||||
for basic network-level SSH access
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
NetBird SSH
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -303,6 +327,15 @@ export function AccessControlModalContent({
|
||||
<Label className={"mb-2"}>
|
||||
<FolderDown size={15} />
|
||||
Source
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Typically a group of user devices (e.g., Developers,
|
||||
Marketing) or individual devices in peer-to-peer
|
||||
connections that will access the destination.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
@@ -337,6 +370,15 @@ export function AccessControlModalContent({
|
||||
<Label className={"mb-2"}>
|
||||
<FolderInput size={15} />
|
||||
Destination
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Typically a group of peers or resources (e.g., Servers,
|
||||
Databases, Internal Services) that will be accessed by
|
||||
the source. Can also be an individual peer or resource.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"destination-group-selector"}
|
||||
@@ -353,8 +395,11 @@ export function AccessControlModalContent({
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
saveGroupAssignments={useSave}
|
||||
additionalResources={additionalResources}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
disableDestinationSelector ||
|
||||
!permission.policies.update ||
|
||||
!permission.policies.create
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -575,7 +620,13 @@ export function AccessControlModalContent({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled || !permission.policies.create}
|
||||
onClick={submit}
|
||||
onClick={() => {
|
||||
if (useSave) {
|
||||
submit();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
data-cy={"submit-policy"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import Button from "@components/Button";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlActionCell({ policy }: Readonly<Props>) {
|
||||
const { confirm } = useDialog();
|
||||
const policyRequest = useApiCall<Route>("/policies");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const deleteRule = async () => {
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully removed.",
|
||||
promise: policyRequest.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
}),
|
||||
loadingMessage: "Deleting the policy...",
|
||||
});
|
||||
};
|
||||
const { deletePolicy } = usePolicies();
|
||||
|
||||
const openConfirm = async () => {
|
||||
const choice = await confirm({
|
||||
@@ -39,7 +25,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
deleteRule().then();
|
||||
await deletePolicy(policy);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { mutate } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
const { updatePolicy } = usePolicies();
|
||||
const { updatePolicy, serializeRules } = usePolicies();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
@@ -19,32 +17,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
}, [policy]);
|
||||
|
||||
const update = async (enabled: boolean) => {
|
||||
const rules = cloneDeep(policy.rules);
|
||||
rules.forEach((rule) => {
|
||||
rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((source) => {
|
||||
const group = source as Group;
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((destination) => {
|
||||
const group = destination as Group;
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
if (rule.sourceResource) {
|
||||
rule.sources = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
policy,
|
||||
{ enabled, rules },
|
||||
{ enabled, rules: serializeRules(policy.rules, enabled) },
|
||||
() => {
|
||||
mutate("/policies");
|
||||
},
|
||||
|
||||
@@ -11,9 +11,13 @@ import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
visiblePorts?: number;
|
||||
};
|
||||
|
||||
export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
export default function AccessControlPortsCell({
|
||||
policy,
|
||||
visiblePorts = 2,
|
||||
}: Readonly<Props>) {
|
||||
const rule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -25,13 +29,13 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
|
||||
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
|
||||
|
||||
const firstTwoPorts = useMemo(() => {
|
||||
return allPorts?.slice(0, 2) ?? [];
|
||||
}, [allPorts]);
|
||||
const visiblePortsList = useMemo(() => {
|
||||
return allPorts?.slice(0, visiblePorts) ?? [];
|
||||
}, [allPorts, visiblePorts]);
|
||||
|
||||
const otherPorts = useMemo(() => {
|
||||
return allPorts?.slice(2) ?? [];
|
||||
}, [allPorts]);
|
||||
return allPorts?.slice(visiblePorts) ?? [];
|
||||
}, [allPorts, visiblePorts]);
|
||||
|
||||
return (
|
||||
<div className={"flex-1"}>
|
||||
@@ -48,7 +52,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{firstTwoPorts?.map((port) => {
|
||||
{visiblePortsList?.map((port) => {
|
||||
return (
|
||||
<Badge
|
||||
key={port}
|
||||
@@ -75,12 +79,8 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{otherPorts && otherPorts.length > 0 && (
|
||||
<TooltipContent>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
|
||||
}
|
||||
>
|
||||
<TooltipContent className={"p-3"}>
|
||||
<div className={"flex gap-2 items-start flex-wrap max-w-sm"}>
|
||||
{otherPorts.map((port) => {
|
||||
return (
|
||||
<Badge key={port} variant={"gray"}>
|
||||
|
||||
@@ -11,9 +11,15 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
hideEdit?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
export default function AccessControlSourcesCell({
|
||||
policy,
|
||||
hideEdit = false,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
@@ -27,12 +33,18 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
canUpdate && !hideEdit && "group",
|
||||
)}
|
||||
>
|
||||
<MultipleGroups
|
||||
groups={firstRule.sources as Group[]}
|
||||
showUsers={firstRule.protocol === "netbird-ssh"}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
{canUpdate && !hideEdit && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
|
||||
@@ -664,6 +664,35 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverse Proxy
|
||||
*/
|
||||
|
||||
if (event.activity_code == "service.peer.expose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> exposed service{" "}
|
||||
<Value>{m.domain}</Value> with auth{" "}
|
||||
<Value>{m.auth ? "Enabled" : "Disabled"}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.unexpose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> unexposed service{" "}
|
||||
<Value>{m.domain}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.expose.expire")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Service <Value>{m.domain}</Value> exposed by peer{" "}
|
||||
<Value>{m.peer_name}</Value> was removed due to renewal expiration
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Networks
|
||||
*/
|
||||
@@ -742,10 +771,16 @@ export default function ActivityDescription({ event }: Props) {
|
||||
*/
|
||||
|
||||
if (event.activity_code == "peer.job.create")
|
||||
return (<div className={"inline"}>
|
||||
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Remote job <Value>{m.job_type}</Value> created for peer{" "}
|
||||
<Value>{m.for_peer_name}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Flow Settings
|
||||
*/
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ export const NetworkRoutingPeerCount = ({ network }: Props) => {
|
||||
}, [network, routerCount]);
|
||||
|
||||
const openNetworkPage = () => {
|
||||
router.push(`/network?id=${network.id}#routing-peers`);
|
||||
router.push(`/network?id=${network.id}&tab=routing-peers`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { NetworkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
||||
import { DeviceCard } from "@components/DeviceCard";
|
||||
|
||||
type NetworkNodeType = {
|
||||
network: Network;
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { DeviceCard } from "@components/DeviceCard";
|
||||
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||
|
||||
type PeerNodeProps = Node<
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
||||
import { DeviceCard } from "@components/DeviceCard";
|
||||
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||
|
||||
type ResourceNode = Node<
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { DeviceCard } from "@components/DeviceCard";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type PeerNodeProps = Node<
|
||||
|
||||
@@ -91,10 +91,11 @@ export function DNSZoneModalContent({
|
||||
if (domain == "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
allowOnlyTld: true,
|
||||
preventLeadingAndTrailingDots: true,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
|
||||
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function DNSRecordsTable({ zone }: Props) {
|
||||
className={"bg-nb-gray-960 py-2"}
|
||||
inset={true}
|
||||
text={"DNS Records"}
|
||||
initialPageSize={zone?.records?.length}
|
||||
manualPagination={true}
|
||||
sorting={sorting}
|
||||
columnVisibility={{}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResourceWithNetwork } from "@/interfaces/Network";
|
||||
@@ -115,67 +116,70 @@ export const GroupResourcesSection = ({
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
<NetworkAccessControlProvider>
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
key={row.network.id + row.name}
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
</NetworkAccessControlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -240,7 +240,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0 ||
|
||||
row.zones_count
|
||||
row.zones_count > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user