Add reverse proxy (#552)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Some checks failed
build and push / build_n_push (push) Has been cancelled
* **New Features** * Full Reverse Proxy UI: Services, Targets, Clusters, Custom Domains (with verification) and a Proxy Events page. * In-app modals for service auth (SSO, password, PIN) and a new PIN input component. * **Improvements** * Network & Peer pages: tabbed views (Resources, Routing Peers, Services) and improved tables, search and filters. * Toast stacking/visibility and global toast styling refined.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,6 +37,8 @@ next-env.d.ts
|
|||||||
|
|
||||||
# config
|
# config
|
||||||
.local-config.json
|
.local-config.json
|
||||||
|
.test-config.json
|
||||||
|
cypress.env.json
|
||||||
.configs/.local-config.zitadel.json
|
.configs/.local-config.zitadel.json
|
||||||
.configs/.staging-config.json
|
.configs/.staging-config.json
|
||||||
.configs/.temp-config.json
|
.configs/.temp-config.json
|
||||||
|
|||||||
69
package-lock.json
generated
69
package-lock.json
generated
@@ -59,7 +59,7 @@
|
|||||||
"ip-cidr": "^3.1.0",
|
"ip-cidr": "^3.1.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"punycode": "^2.3.1",
|
"punycode": "^2.3.1",
|
||||||
@@ -67,7 +67,6 @@
|
|||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-ga4": "^2.1.0",
|
"react-ga4": "^2.1.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
|
||||||
"react-hotjar": "^6.3.1",
|
"react-hotjar": "^6.3.1",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
@@ -75,6 +74,7 @@
|
|||||||
"react-loading-skeleton": "^3.3.1",
|
"react-loading-skeleton": "^3.3.1",
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-virtuoso": "^4.9.0",
|
"react-virtuoso": "^4.9.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.2.4",
|
"swr": "^2.2.4",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -90,6 +90,9 @@
|
|||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prettier": "3.0.3",
|
"prettier": "3.0.3",
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -165,6 +168,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -3027,6 +3031,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3036,6 +3041,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3094,6 +3100,7 @@
|
|||||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.54.0",
|
"@typescript-eslint/scope-manager": "8.54.0",
|
||||||
"@typescript-eslint/types": "8.54.0",
|
"@typescript-eslint/types": "8.54.0",
|
||||||
@@ -3600,7 +3607,8 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.10.0",
|
"version": "12.10.0",
|
||||||
@@ -3639,6 +3647,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4056,6 +4065,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -4684,6 +4694,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -5212,6 +5223,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5409,6 +5421,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -6010,15 +6023,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -6918,9 +6923,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.539.0",
|
"version": "0.562.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||||
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
|
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -7465,6 +7470,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -7672,6 +7678,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -7712,6 +7719,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -7725,23 +7733,6 @@
|
|||||||
"integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==",
|
"integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-hotjar": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hotjar/-/react-hotjar-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hotjar/-/react-hotjar-6.3.1.tgz",
|
||||||
@@ -8336,6 +8327,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -8779,6 +8781,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -8944,6 +8947,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -9271,6 +9275,7 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"ip-cidr": "^3.1.0",
|
"ip-cidr": "^3.1.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"punycode": "^2.3.1",
|
"punycode": "^2.3.1",
|
||||||
@@ -75,7 +75,6 @@
|
|||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-ga4": "^2.1.0",
|
"react-ga4": "^2.1.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
|
||||||
"react-hotjar": "^6.3.1",
|
"react-hotjar": "^6.3.1",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
@@ -83,6 +82,7 @@
|
|||||||
"react-loading-skeleton": "^3.3.1",
|
"react-loading-skeleton": "^3.3.1",
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-virtuoso": "^4.9.0",
|
"react-virtuoso": "^4.9.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.2.4",
|
"swr": "^2.2.4",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
78
src/app/(dashboard)/events/proxy/page.tsx
Normal file
78
src/app/(dashboard)/events/proxy/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"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(),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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.
|
in our documentation.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
|
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
|
||||||
<span>
|
<span>
|
||||||
We recommend using the new Networks concept to easier visualise
|
We recommend using the new Networks concept to easier visualise
|
||||||
and manage access to your resources.{" "}
|
and manage access to your resources.{" "}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import {
|
|||||||
} from "@components/DropdownMenu";
|
} from "@components/DropdownMenu";
|
||||||
import FullTooltip from "@components/FullTooltip";
|
import FullTooltip from "@components/FullTooltip";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import Separator from "@components/Separator";
|
|
||||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||||
import useRedirect from "@hooks/useRedirect";
|
import useRedirect from "@hooks/useRedirect";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import { cn } from "@utils/helpers";
|
import { cn, singularize } from "@utils/helpers";
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
Layers3Icon,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
PencilLineIcon,
|
PencilLineIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -28,19 +28,27 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { useSWRConfig } from "swr";
|
import useUrlTab from "@/hooks/useUrlTab";
|
||||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { Network } from "@/interfaces/Network";
|
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||||
import {
|
import {
|
||||||
NetworkProvider,
|
NetworkProvider,
|
||||||
useNetworksContext,
|
useNetworksContext,
|
||||||
} from "@/modules/networks/NetworkProvider";
|
} from "@/modules/networks/NetworkProvider";
|
||||||
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
|
import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent";
|
||||||
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
|
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";
|
||||||
|
|
||||||
export default function NetworkDetailPage() {
|
export default function NetworkDetailPage() {
|
||||||
const queryParameter = useSearchParams();
|
const queryParameter = useSearchParams();
|
||||||
@@ -53,7 +61,9 @@ export default function NetworkDetailPage() {
|
|||||||
useRedirect("/networks", false, !networkId);
|
useRedirect("/networks", false, !networkId);
|
||||||
|
|
||||||
return network && !isLoading ? (
|
return network && !isLoading ? (
|
||||||
|
<ReverseProxiesProvider initialNetwork={network}>
|
||||||
<NetworkOverview network={network} />
|
<NetworkOverview network={network} />
|
||||||
|
</ReverseProxiesProvider>
|
||||||
) : (
|
) : (
|
||||||
<FullScreenLoading />
|
<FullScreenLoading />
|
||||||
);
|
);
|
||||||
@@ -62,8 +72,23 @@ export default function NetworkDetailPage() {
|
|||||||
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
const [networkModal, setNetworkModal] = useState(false);
|
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||||
const { mutate } = useSWRConfig();
|
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 = !!(
|
const isActive = !!(
|
||||||
network?.routing_peers_count && network.routing_peers_count > 0
|
network?.routing_peers_count && network.routing_peers_count > 0
|
||||||
@@ -72,7 +97,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<NetworkProvider network={network}>
|
<NetworkProvider network={network}>
|
||||||
<div className={"p-default py-6 mb-4"}>
|
<div className={"p-default py-6"}>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
href={"/networks"}
|
href={"/networks"}
|
||||||
@@ -115,11 +140,58 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Tabs
|
||||||
<ResourcesSection network={network} />
|
defaultValue={tab}
|
||||||
<div className={"h-3"} />
|
onValueChange={setTab}
|
||||||
<Separator />
|
value={tab}
|
||||||
<NetworkRoutingPeersSection network={network} />
|
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>
|
||||||
|
|
||||||
|
<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>
|
</NetworkProvider>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
|||||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||||
import useRedirect from "@hooks/useRedirect";
|
import useRedirect from "@hooks/useRedirect";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
|
import { singularize } from "@utils/helpers";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { isEmpty, trim } from "lodash";
|
import { isEmpty, trim } from "lodash";
|
||||||
import {
|
import {
|
||||||
@@ -36,13 +37,14 @@ import {
|
|||||||
FlagIcon,
|
FlagIcon,
|
||||||
Globe,
|
Globe,
|
||||||
History,
|
History,
|
||||||
|
ListIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
MonitorSmartphoneIcon,
|
MonitorSmartphoneIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RadioTowerIcon,
|
RadioTowerIcon,
|
||||||
TimerResetIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { toASCII } from "punycode";
|
import { toASCII } from "punycode";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
@@ -52,21 +54,27 @@ import RoundedFlag from "@/assets/countries/RoundedFlag";
|
|||||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||||
|
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||||
import { useCountries } from "@/contexts/CountryProvider";
|
import { useCountries } from "@/contexts/CountryProvider";
|
||||||
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||||
|
import type { Group } from "@/interfaces/Group";
|
||||||
import type { Peer } from "@/interfaces/Peer";
|
import type { Peer } from "@/interfaces/Peer";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||||
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
|
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 { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||||
import Link from "next/link";
|
|
||||||
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
|
||||||
|
|
||||||
export default function PeerPage() {
|
export default function PeerPage() {
|
||||||
@@ -99,10 +107,12 @@ export default function PeerPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return peer && !isLoading ? (
|
return peer && peer.id && !isLoading ? (
|
||||||
|
<ReverseProxiesProvider initialPeer={peer}>
|
||||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||||
<PeerOverview key={peer?.id} />
|
<PeerOverview key={peer?.id} />
|
||||||
</PeerProvider>
|
</PeerProvider>
|
||||||
|
</ReverseProxiesProvider>
|
||||||
) : (
|
) : (
|
||||||
<FullScreenLoading />
|
<FullScreenLoading />
|
||||||
);
|
);
|
||||||
@@ -114,6 +124,7 @@ function PeerOverview() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<RoutesProvider>
|
<RoutesProvider>
|
||||||
|
<PeerSettingsProvider>
|
||||||
<div className={"p-default py-6 pb-0"}>
|
<div className={"p-default py-6 pb-0"}>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
@@ -123,29 +134,50 @@ function PeerOverview() {
|
|||||||
/>
|
/>
|
||||||
<Breadcrumbs.Item label={peer.ip} active />
|
<Breadcrumbs.Item label={peer.ip} active />
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
<PeerGeneralInformation />
|
<PeerHeader />
|
||||||
</div>
|
</div>
|
||||||
<PeerOverviewTabs />
|
<PeerOverviewTabs />
|
||||||
|
</PeerSettingsProvider>
|
||||||
</RoutesProvider>
|
</RoutesProvider>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PeerGeneralInformation = () => {
|
type PeerSettingsContextType = {
|
||||||
const router = useRouter();
|
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 { mutate } = useSWRConfig();
|
||||||
const { peer, user, peerGroups, update } = usePeer();
|
const { peer, peerGroups, update } = usePeer();
|
||||||
|
const { permission } = usePermissions();
|
||||||
const [name, setName] = useState(peer.name);
|
const [name, setName] = useState(peer.name);
|
||||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
const [tab, setTab] = useState("overview");
|
||||||
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
|
||||||
useGroupHelper({
|
useGroupHelper({
|
||||||
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
initial: peerGroups?.filter((g) => g?.name !== "All"),
|
||||||
peer,
|
peer,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
|
||||||
*/
|
|
||||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||||
selectedGroups,
|
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 { permission } = usePermissions();
|
||||||
|
const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings();
|
||||||
|
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||||
|
const isOverviewTab = tab === "overview";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -236,6 +292,7 @@ const PeerGeneralInformation = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isOverviewTab && (
|
||||||
<div className={"flex gap-4"}>
|
<div className={"flex gap-4"}>
|
||||||
<Button
|
<Button
|
||||||
variant={"default"}
|
variant={"default"}
|
||||||
@@ -249,36 +306,131 @@ const PeerGeneralInformation = () => {
|
|||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
onClick={() => updatePeer()}
|
onClick={() => updatePeer()}
|
||||||
disabled={
|
disabled={
|
||||||
!hasChanges || !permission.peers.read || !permission.groups.update
|
!hasChanges ||
|
||||||
|
!permission.peers.update ||
|
||||||
|
!permission.groups.update
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</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
|
<div
|
||||||
className={
|
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} />
|
<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 />
|
<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 && (
|
{permission.groups.read && (
|
||||||
<div>
|
<div>
|
||||||
<Label>Assigned Groups</Label>
|
<Label>Assigned Groups</Label>
|
||||||
@@ -294,67 +446,21 @@ const PeerGeneralInformation = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function PeersBlockedView() {
|
|||||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||||
<div
|
<div
|
||||||
className={
|
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} />
|
<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 height={"auto"} />;
|
||||||
|
}
|
||||||
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
|
Authentication
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
{account?.settings?.embedded_idp_enabled &&
|
{account?.settings?.embedded_idp_enabled &&
|
||||||
permission.identity_providers.read && (
|
permission?.identity_providers?.read && (
|
||||||
<VerticalTabs.Trigger value="identity-providers">
|
<VerticalTabs.Trigger value="identity-providers">
|
||||||
<FingerprintIcon size={14} />
|
<FingerprintIcon size={14} />
|
||||||
Identity Providers
|
Identity Providers
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--toasts-before: 0;
|
||||||
|
--lift: 1;
|
||||||
|
}
|
||||||
|
|
||||||
html{
|
html{
|
||||||
@apply bg-nb-gray;
|
@apply bg-nb-gray;
|
||||||
}
|
}
|
||||||
@@ -171,6 +176,25 @@ p {
|
|||||||
@apply m-0 p-0 box-border;
|
@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 */
|
/* Control Center */
|
||||||
.react-flow__node-groupNode .selected{
|
.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} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
15
src/assets/icons/ReverseProxyIcon.tsx
Normal file
15
src/assets/icons/ReverseProxyIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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)}
|
||||||
|
>
|
||||||
|
<path 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,
|
OidcProvider,
|
||||||
} from "@axa-fr/react-oidc";
|
} from "@axa-fr/react-oidc";
|
||||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
|
||||||
import loadConfig, { buildExtras } from "@utils/config";
|
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 React, { useEffect, useState } from "react";
|
||||||
import { OIDCError } from "@/auth/OIDCError";
|
import { OIDCError } from "@/auth/OIDCError";
|
||||||
import { SecureProvider } from "@/auth/SecureProvider";
|
import { SecureProvider } from "@/auth/SecureProvider";
|
||||||
@@ -43,33 +42,6 @@ export default function OIDCProvider({ children }: Props) {
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const path = usePathname();
|
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 = () => {
|
const withCustomHistory = () => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import { usePathname } from "next/navigation";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const QUERY_PARAMS_KEY = "netbird-query-params";
|
||||||
|
const VALID_PARAMS = [
|
||||||
|
"tab",
|
||||||
|
"search",
|
||||||
|
"id",
|
||||||
|
"invite",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_content",
|
||||||
|
"utm_campaign",
|
||||||
|
"hs_id",
|
||||||
|
"page",
|
||||||
|
"page_size",
|
||||||
|
"user",
|
||||||
|
"port",
|
||||||
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
@@ -10,6 +27,22 @@ export const SecureProvider = ({ children }: Props) => {
|
|||||||
const { isAuthenticated, login } = useOidc();
|
const { isAuthenticated, login } = useOidc();
|
||||||
const currentPath = usePathname();
|
const currentPath = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
localStorage.removeItem(QUERY_PARAMS_KEY);
|
||||||
|
} else {
|
||||||
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -25,8 +25,6 @@ export const Item = ({
|
|||||||
active,
|
active,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: ItemProps) => {
|
}: ItemProps) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -45,7 +43,13 @@ export const Item = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon && icon}
|
{icon && icon}
|
||||||
{href ? <span onClick={() => router.push(href)}>{label}</span> : label}
|
{href ? (
|
||||||
|
<Link href={href} data-cy={"breadcrumb-item"}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const buttonVariants = cva(
|
|||||||
dotted: [
|
dotted: [
|
||||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
"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: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: [
|
tertiary: [
|
||||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
"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",
|
"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",
|
||||||
|
],
|
||||||
"default-outline": [
|
"default-outline": [
|
||||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
"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",
|
"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",
|
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",
|
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
|
||||||
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("flex gap-2 items-center group cursor-pointer", className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -34,27 +30,34 @@ export default function CopyToClipboardText({
|
|||||||
}}
|
}}
|
||||||
ref={wrapper}
|
ref={wrapper}
|
||||||
>
|
>
|
||||||
|
<span className="relative truncate">
|
||||||
{children}
|
{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
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
"text-nb-gray-100 group-hover:opacity-100",
|
||||||
iconAlignment === "left" ? "order-first" : "order-last",
|
!copied && "hidden",
|
||||||
!alwaysShowIcon && "opacity-0",
|
!alwaysShowIcon && !copied && "opacity-0",
|
||||||
)}
|
)}
|
||||||
size={11}
|
size={11}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<CopyIcon
|
<CopyIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
|
"text-nb-gray-100 group-hover:opacity-100",
|
||||||
iconAlignment === "left" ? "order-first" : "order-last",
|
copied && "hidden",
|
||||||
!alwaysShowIcon && "opacity-0",
|
!alwaysShowIcon && "opacity-0",
|
||||||
)}
|
)}
|
||||||
size={11}
|
size={11}
|
||||||
/>
|
/>
|
||||||
)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Props {
|
|||||||
value?: DateRange;
|
value?: DateRange;
|
||||||
onChange?: (range: DateRange | undefined) => void;
|
onChange?: (range: DateRange | undefined) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRanges = {
|
const defaultRanges = {
|
||||||
@@ -61,6 +62,7 @@ export function DatePickerWithRange({
|
|||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled = false,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const isActive = useMemo(() => {
|
const isActive = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -120,6 +122,7 @@ export function DatePickerWithRange({
|
|||||||
<Button
|
<Button
|
||||||
id="date"
|
id="date"
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
|
disabled={disabled}
|
||||||
className={cn("max-w-[260px] justify-start text-left font-normal")}
|
className={cn("max-w-[260px] justify-start text-left font-normal")}
|
||||||
>
|
>
|
||||||
<CalendarIcon size={16} className={"shrink-0"} />
|
<CalendarIcon size={16} className={"shrink-0"} />
|
||||||
|
|||||||
93
src/components/DeviceCard.tsx
Normal file
93
src/components/DeviceCard.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
<div>{children && value ? children : null}</div>
|
{children && value ? (
|
||||||
|
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/components/HelpTooltip.tsx
Normal file
30
src/components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import FullTooltip from "@components/FullTooltip";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
interactive?: boolean;
|
||||||
|
};
|
||||||
|
export const HelpTooltip = ({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
interactive = true,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FullTooltip
|
||||||
|
interactive={interactive}
|
||||||
|
side={"top"}
|
||||||
|
align={"start"}
|
||||||
|
alignOffset={0}
|
||||||
|
className={
|
||||||
|
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
|
||||||
|
}
|
||||||
|
content={content}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FullTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -127,6 +127,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
suffix && "!pr-16",
|
suffix && "!pr-16",
|
||||||
icon && "!pl-10",
|
icon && "!pl-10",
|
||||||
"border",
|
"border",
|
||||||
|
props.readOnly &&
|
||||||
|
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
|
||||||
className,
|
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",
|
"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<
|
type LabelProps = React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
VariantProps<typeof labelVariants> & {
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
as?: "label" | "div";
|
||||||
VariantProps<typeof labelVariants>
|
};
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
|
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
|
<LabelPrimitive.Root
|
||||||
ref={ref}
|
ref={ref as React.Ref<HTMLLabelElement>}
|
||||||
className={cn(labelVariants(), className, "select-none")}
|
className={classes}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
));
|
{children}
|
||||||
|
</LabelPrimitive.Root>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Label.displayName = LabelPrimitive.Root.displayName;
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Label };
|
export { Label };
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { IconCircleX } from "@tabler/icons-react";
|
|||||||
import type { ErrorResponse } from "@utils/api";
|
import type { ErrorResponse } from "@utils/api";
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { CheckIcon, Loader2, XIcon } from "lucide-react";
|
import { CheckIcon, Loader2, XIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast, { type Toast } from "react-hot-toast";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export interface NotifyProps<T> {
|
export interface NotifyProps<T> {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,14 +22,15 @@ export interface NotifyProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationProps<T> extends NotifyProps<T> {
|
interface NotificationProps<T> extends NotifyProps<T> {
|
||||||
t: Toast;
|
toastId: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Notification<T>({
|
export default function Notification<T>({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
t,
|
toastId,
|
||||||
promise,
|
promise,
|
||||||
loadingTitle,
|
loadingTitle,
|
||||||
loadingMessage,
|
loadingMessage,
|
||||||
@@ -39,17 +40,65 @@ export default function Notification<T>({
|
|||||||
}: NotificationProps<T>) {
|
}: NotificationProps<T>) {
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(!!promise);
|
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 = () => {
|
const pauseTimer = useCallback(() => {
|
||||||
setTimeout(() => {
|
if (!timerRef.current || !startTimeRef.current) return;
|
||||||
setLoading(false);
|
clearTimeout(timerRef.current);
|
||||||
toast.dismiss(t.id);
|
timerRef.current = null;
|
||||||
}, toastDuration);
|
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(() => {
|
useEffect(() => {
|
||||||
// Run the promise
|
// Run the promise
|
||||||
@@ -57,8 +106,11 @@ export default function Notification<T>({
|
|||||||
promise
|
promise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
closeToast();
|
if (preventSuccessToast) {
|
||||||
if (preventSuccessToast) setPreventSuccess(true);
|
toast.dismiss(toastId);
|
||||||
|
} else {
|
||||||
|
setReadyToDismiss(true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
const err = e as ErrorResponse;
|
const err = e as ErrorResponse;
|
||||||
@@ -78,23 +130,24 @@ export default function Notification<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
closeToast();
|
setReadyToDismiss(true);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
closeToast();
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
|
||||||
{t.visible && !preventSuccess && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 1, y: -50 }}
|
ref={notificationRef}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ y: -20 }}
|
||||||
exit={{ opacity: 0, y: -50 }}
|
animate={{ y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 20 }}
|
||||||
|
data-toast-notification
|
||||||
|
className="w-[28rem] pb-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={cn(
|
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",
|
"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={"flex items-center gap-4"}>
|
||||||
@@ -122,9 +175,7 @@ export default function Notification<T>({
|
|||||||
{loading ? loadingTitle || title : title}
|
{loading ? loadingTitle || title : title}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}>
|
||||||
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
|
|
||||||
>
|
|
||||||
{loading ? loadingMessage : error ? error : description}
|
{loading ? loadingMessage : error ? error : description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +183,7 @@ export default function Notification<T>({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
|
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
|
||||||
onClick={() => toast.dismiss(t.id)}
|
onClick={() => toast.dismiss(toastId)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -142,14 +193,13 @@ export default function Notification<T>({
|
|||||||
<XIcon size={16} />
|
<XIcon size={16} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notify<T>(props: NotifyProps<T>) {
|
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,
|
duration: Infinity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ import { PolicyRuleResource } from "@/interfaces/Policy";
|
|||||||
import { User } from "@/interfaces/User";
|
import { User } from "@/interfaces/User";
|
||||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||||
|
import TruncatedText from "@components/ui/TruncatedText";
|
||||||
|
|
||||||
|
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
|
||||||
|
|
||||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||||
const lowerCaseQuery = query.toLowerCase();
|
const lowerCaseQuery = query.toLowerCase();
|
||||||
@@ -68,6 +71,9 @@ interface MultiSelectProps {
|
|||||||
showResourceCounter?: boolean;
|
showResourceCounter?: boolean;
|
||||||
showResources?: boolean;
|
showResources?: boolean;
|
||||||
showPeers?: boolean;
|
showPeers?: boolean;
|
||||||
|
hideGroupsTab?: boolean;
|
||||||
|
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||||
|
closeOnSelect?: boolean;
|
||||||
resource?: PolicyRuleResource;
|
resource?: PolicyRuleResource;
|
||||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -76,6 +82,7 @@ interface MultiSelectProps {
|
|||||||
side?: "top" | "bottom";
|
side?: "top" | "bottom";
|
||||||
users?: User[];
|
users?: User[];
|
||||||
placeholderForSearch?: string;
|
placeholderForSearch?: string;
|
||||||
|
resourceIds?: string[];
|
||||||
}
|
}
|
||||||
export function PeerGroupSelector({
|
export function PeerGroupSelector({
|
||||||
onChange,
|
onChange,
|
||||||
@@ -94,6 +101,9 @@ export function PeerGroupSelector({
|
|||||||
showResourceCounter = true,
|
showResourceCounter = true,
|
||||||
showResources = false,
|
showResources = false,
|
||||||
showPeers = false,
|
showPeers = false,
|
||||||
|
hideGroupsTab = false,
|
||||||
|
tabOrder,
|
||||||
|
closeOnSelect = false,
|
||||||
resource,
|
resource,
|
||||||
onResourceChange,
|
onResourceChange,
|
||||||
placeholder = "Add or select group(s)...",
|
placeholder = "Add or select group(s)...",
|
||||||
@@ -102,6 +112,7 @@ export function PeerGroupSelector({
|
|||||||
side = "bottom",
|
side = "bottom",
|
||||||
users,
|
users,
|
||||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||||
|
resourceIds,
|
||||||
}: Readonly<MultiSelectProps>) {
|
}: Readonly<MultiSelectProps>) {
|
||||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||||
NetworkResource[]
|
NetworkResource[]
|
||||||
@@ -229,7 +240,13 @@ export function PeerGroupSelector({
|
|||||||
|
|
||||||
const [slice, setSlice] = useState(10);
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -272,6 +289,9 @@ export function PeerGroupSelector({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
onChange([]);
|
onChange([]);
|
||||||
|
if (closeOnSelect) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectPeer = (peer?: Peer) => {
|
const selectPeer = (peer?: Peer) => {
|
||||||
@@ -281,6 +301,9 @@ export function PeerGroupSelector({
|
|||||||
type: "peer",
|
type: "peer",
|
||||||
});
|
});
|
||||||
onChange([]);
|
onChange([]);
|
||||||
|
if (closeOnSelect) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -438,11 +461,20 @@ export function PeerGroupSelector({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<TabTriggers
|
||||||
searchRef={searchRef}
|
searchRef={searchRef}
|
||||||
showPeers={showPeers}
|
showPeers={showPeers}
|
||||||
showResources={showResources}
|
showResources={showResources}
|
||||||
|
hideGroupsTab={hideGroupsTab}
|
||||||
|
tabOrder={tabOrder}
|
||||||
/>
|
/>
|
||||||
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -562,7 +594,11 @@ export function PeerGroupSelector({
|
|||||||
<TabsContent value={"resources"} className={"p-0 my-0"}>
|
<TabsContent value={"resources"} className={"p-0 my-0"}>
|
||||||
<ResourcesList
|
<ResourcesList
|
||||||
search={search}
|
search={search}
|
||||||
resources={resources}
|
resources={
|
||||||
|
resourceIds
|
||||||
|
? resources?.filter((r) => resourceIds.includes(r.id))
|
||||||
|
: resources
|
||||||
|
}
|
||||||
isLoading={isResourcesLoading}
|
isLoading={isResourcesLoading}
|
||||||
value={resource}
|
value={resource}
|
||||||
onChange={selectResource}
|
onChange={selectResource}
|
||||||
@@ -592,16 +628,22 @@ const TabTriggers = ({
|
|||||||
searchRef,
|
searchRef,
|
||||||
showResources = false,
|
showResources = false,
|
||||||
showPeers = false,
|
showPeers = false,
|
||||||
|
hideGroupsTab = false,
|
||||||
|
tabOrder,
|
||||||
}: {
|
}: {
|
||||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||||
showResources?: boolean;
|
showResources?: boolean;
|
||||||
showPeers?: 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;
|
||||||
|
|
||||||
return (
|
const groupsTab = !hideGroupsTab && (
|
||||||
<TabsList justify={"start"} className={"px-3"}>
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
key="groups"
|
||||||
value={"groups"}
|
value={"groups"}
|
||||||
className={"text-[.8rem] font-normal"}
|
className={"text-[.8rem] font-normal"}
|
||||||
onClick={() => searchRef.current?.focus()}
|
onClick={() => searchRef.current?.focus()}
|
||||||
@@ -614,9 +656,11 @@ const TabTriggers = ({
|
|||||||
/>
|
/>
|
||||||
Groups
|
Groups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
|
||||||
{showResources && (
|
const resourcesTab = showResources && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
key="resources"
|
||||||
value={"resources"}
|
value={"resources"}
|
||||||
className={"text-[.8rem] font-normal"}
|
className={"text-[.8rem] font-normal"}
|
||||||
onClick={() => searchRef.current?.focus()}
|
onClick={() => searchRef.current?.focus()}
|
||||||
@@ -629,10 +673,11 @@ const TabTriggers = ({
|
|||||||
/>
|
/>
|
||||||
Resources
|
Resources
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
);
|
||||||
|
|
||||||
{showPeers && (
|
const peersTab = showPeers && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
key="peers"
|
||||||
value={"peers"}
|
value={"peers"}
|
||||||
className={"text-[.8rem] font-normal"}
|
className={"text-[.8rem] font-normal"}
|
||||||
onClick={() => searchRef.current?.focus()}
|
onClick={() => searchRef.current?.focus()}
|
||||||
@@ -645,7 +690,27 @@ const TabTriggers = ({
|
|||||||
/>
|
/>
|
||||||
Peers
|
Peers
|
||||||
</TabsTrigger>
|
</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"}>
|
||||||
|
{groupsTab}
|
||||||
|
{resourcesTab}
|
||||||
|
{peersTab}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -787,6 +852,7 @@ const ResourcesList = ({
|
|||||||
<VirtualScrollAreaList
|
<VirtualScrollAreaList
|
||||||
items={filteredItems}
|
items={filteredItems}
|
||||||
onSelect={onChange}
|
onSelect={onChange}
|
||||||
|
estimatedItemHeight={42}
|
||||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||||
renderItem={(res) => {
|
renderItem={(res) => {
|
||||||
return (
|
return (
|
||||||
@@ -896,6 +962,7 @@ const PeersList = ({
|
|||||||
<VirtualScrollAreaList
|
<VirtualScrollAreaList
|
||||||
items={filteredItems}
|
items={filteredItems}
|
||||||
onSelect={onChange}
|
onSelect={onChange}
|
||||||
|
estimatedItemHeight={42}
|
||||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||||
renderItem={(res) => {
|
renderItem={(res) => {
|
||||||
if (!res?.id) return;
|
if (!res?.id) return;
|
||||||
@@ -904,7 +971,7 @@ const PeersList = ({
|
|||||||
<Fragment key={res.id}>
|
<Fragment key={res.id}>
|
||||||
<div className={"flex items-center gap-2"}>
|
<div className={"flex items-center gap-2"}>
|
||||||
<Badge
|
<Badge
|
||||||
useHover={true}
|
useHover={false}
|
||||||
data-cy={"group-badge"}
|
data-cy={"group-badge"}
|
||||||
variant={"gray-ghost"}
|
variant={"gray-ghost"}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -915,7 +982,7 @@ const PeersList = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PeerOperatingSystemIcon os={res.os} />
|
<PeerOperatingSystemIcon os={res.os} />
|
||||||
<TextWithTooltip text={res?.name || ""} maxChars={20} />
|
<TruncatedText text={res?.name || ""} maxWidth={"270px"} />
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
import { useElementSize } from "@/hooks/useElementSize";
|
import { useElementSize } from "@/hooks/useElementSize";
|
||||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
|
||||||
import { Peer } from "@/interfaces/Peer";
|
import { Peer } from "@/interfaces/Peer";
|
||||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
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",
|
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||||
)}
|
)}
|
||||||
data-cy={"port-input"}
|
data-cy={"port-input"}
|
||||||
typeof={"number"}
|
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -10,10 +9,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RadioGroup = ({ value, onChange, children }: Props) => {
|
export const RadioGroup = ({ value, onChange, children }: Props) => {
|
||||||
const [defaultValue] = useState(value);
|
|
||||||
return (
|
return (
|
||||||
<RadixRadioGroup.Root
|
<RadixRadioGroup.Root
|
||||||
defaultValue={defaultValue}
|
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
className={
|
className={
|
||||||
|
|||||||
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 classNames from "classnames";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import React, { useMemo } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||||
|
|
||||||
export type SidebarItemProps = {
|
export type SidebarItemProps = {
|
||||||
@@ -36,8 +36,22 @@ export default function SidebarItem({
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
visible,
|
visible,
|
||||||
}: Readonly<SidebarItemProps>) {
|
}: Readonly<SidebarItemProps>) {
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const path = usePathname();
|
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 router = useRouter();
|
||||||
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
|
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
|
||||||
useApplicationContext();
|
useApplicationContext();
|
||||||
@@ -48,6 +62,7 @@ export default function SidebarItem({
|
|||||||
? path == href
|
? path == href
|
||||||
: path.includes(href)
|
: path.includes(href)
|
||||||
: false;
|
: false;
|
||||||
|
if (collapsible && href) return;
|
||||||
if (collapsible && mobileNavOpen) return;
|
if (collapsible && mobileNavOpen) return;
|
||||||
if (collapsible && open) return;
|
if (collapsible && open) return;
|
||||||
if (preventRedirect) return;
|
if (preventRedirect) return;
|
||||||
@@ -66,7 +81,7 @@ export default function SidebarItem({
|
|||||||
return (
|
return (
|
||||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||||
<Collapsible.Trigger asChild>
|
<Collapsible.Trigger asChild>
|
||||||
<li className={"px-4 cursor-pointer list-none"}>
|
<li className={"px-3 cursor-pointer list-none"}>
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"rounded-lg text-[.87rem] w-full relative font-normal",
|
"rounded-lg text-[.87rem] w-full relative font-normal",
|
||||||
@@ -101,7 +116,7 @@ export default function SidebarItem({
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 whitespace-nowrap flex-1 w-full text-left",
|
"px-3 whitespace-nowrap flex-1 w-full text-left",
|
||||||
labelClassName,
|
labelClassName,
|
||||||
isNavigationCollapsed &&
|
isNavigationCollapsed &&
|
||||||
!mobileNavOpen &&
|
!mobileNavOpen &&
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { MemoizedScrollArea, ScrollAreaViewport } from "@components/ScrollArea";
|
||||||
MemoizedScrollArea,
|
|
||||||
ScrollAreaViewport,
|
|
||||||
} from "@components/ScrollArea";
|
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const ModalContent = React.forwardRef<
|
|||||||
children,
|
children,
|
||||||
showClose = true,
|
showClose = true,
|
||||||
maxWidthClass = "max-w-3xl",
|
maxWidthClass = "max-w-3xl",
|
||||||
|
onPointerDownOutside,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -72,6 +73,19 @@ const ModalContent = React.forwardRef<
|
|||||||
className,
|
className,
|
||||||
maxWidthClass,
|
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}
|
{...props}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as React from "react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Skeleton from "react-loading-skeleton";
|
import Skeleton from "react-loading-skeleton";
|
||||||
import { useElementSize } from "@/hooks/useElementSize";
|
import { useElementSize } from "@/hooks/useElementSize";
|
||||||
|
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
label: string | React.ReactNode;
|
label: string | React.ReactNode;
|
||||||
@@ -25,13 +26,16 @@ export interface SelectOption {
|
|||||||
}>;
|
}>;
|
||||||
renderItem?: () => React.ReactNode;
|
renderItem?: () => React.ReactNode;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectDropdownProps {
|
interface SelectDropdownProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
popoverWidth?: "auto" | number;
|
popoverWidth?: "auto" | "content" | number;
|
||||||
|
popoverMinWidth?: number;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showValues?: boolean;
|
showValues?: boolean;
|
||||||
@@ -51,6 +55,7 @@ export function SelectDropdown({
|
|||||||
value,
|
value,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
popoverWidth = "auto",
|
popoverWidth = "auto",
|
||||||
|
popoverMinWidth,
|
||||||
options,
|
options,
|
||||||
showSearch = false,
|
showSearch = false,
|
||||||
showValues = false,
|
showValues = false,
|
||||||
@@ -169,9 +174,18 @@ export function SelectDropdown({
|
|||||||
)}
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<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={{
|
style={{
|
||||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
width:
|
||||||
|
popoverWidth === "content"
|
||||||
|
? "auto"
|
||||||
|
: popoverWidth === "auto"
|
||||||
|
? width
|
||||||
|
: popoverWidth,
|
||||||
|
minWidth: popoverMinWidth,
|
||||||
}}
|
}}
|
||||||
align="start"
|
align="start"
|
||||||
side={"bottom"}
|
side={"bottom"}
|
||||||
@@ -194,9 +208,10 @@ export function SelectDropdown({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredItems.length == 0 && (
|
{filteredItems.length == 0 && (
|
||||||
<div className={"text-center pb-2 px-3 text-nb-gray-400 text-xs"}>
|
<DropdownInfoText className={"max-w-sm mx-auto px-4"}>
|
||||||
There are no results matching your search.
|
There are no results matching your search. Please try a
|
||||||
</div>
|
different search term.
|
||||||
|
</DropdownInfoText>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
@@ -209,7 +224,7 @@ export function SelectDropdown({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<div className={"grid grid-cols-1 gap-1 pb-2"}>
|
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
|
||||||
{filteredItems.map((option) => (
|
{filteredItems.map((option) => (
|
||||||
<SelectDropdownItem
|
<SelectDropdownItem
|
||||||
option={option}
|
option={option}
|
||||||
@@ -253,22 +268,29 @@ const SelectDropdownItem = ({
|
|||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={elementRef} className={"transition-all"}>
|
<div ref={elementRef} className={"transition-all w-full"}>
|
||||||
{visible ? (
|
{visible ? (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={option?.searchValue ?? value}
|
value={option?.searchValue ?? value}
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
className={"py-1 px-2"}
|
className={"py-1 px-2 w-full"}
|
||||||
onSelect={() => toggle(option.value)}
|
onSelect={() => !option?.disabled && toggle(option.value)}
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
|
disabled={option?.disabled}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 p-1 w-full",
|
||||||
|
option?.className,
|
||||||
|
option?.disabled && "cursor-not-allowed",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className={"flex items-center gap-2.5 p-1"}>
|
|
||||||
{option.icon && <option.icon size={14} width={14} />}
|
{option.icon && <option.icon size={14} width={14} />}
|
||||||
{option?.renderItem && option.renderItem()}
|
{option?.renderItem && option.renderItem()}
|
||||||
{!option?.renderItem && (
|
{!option?.renderItem && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col text-sm font-medium",
|
"flex flex-col text-sm font-medium w-full",
|
||||||
size === "xs" && "text-xs",
|
size === "xs" && "text-xs",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
16
src/components/skeletons/SkeletonDeviceCard.tsx
Normal file
16
src/components/skeletons/SkeletonDeviceCard.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import Skeleton from "react-loading-skeleton";
|
||||||
|
|
||||||
|
export const SkeletonDeviceCard = () => {
|
||||||
|
return (
|
||||||
|
<div className={"min-h-[59px] relative -left-2"}>
|
||||||
|
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -133,9 +133,10 @@ interface DataTableProps<TData, TValue> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
as?: "div" | "table";
|
as?: "div" | "table";
|
||||||
paginationClassName?: string;
|
paginationClassName?: string;
|
||||||
rowClassName?: string;
|
rowClassName?: string | ((row: Row<TData>) => string);
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
tableClassName?: string;
|
tableClassName?: string;
|
||||||
searchClassName?: string;
|
searchClassName?: string;
|
||||||
@@ -150,6 +151,8 @@ interface DataTableProps<TData, TValue> {
|
|||||||
useRowId?: boolean;
|
useRowId?: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
showResetFilterButton?: boolean;
|
showResetFilterButton?: boolean;
|
||||||
|
serverSidePagination?: boolean;
|
||||||
|
hasServerSideFilters?: boolean;
|
||||||
onFilterReset?: () => void;
|
onFilterReset?: () => void;
|
||||||
wrapperComponent?: React.ElementType;
|
wrapperComponent?: React.ElementType;
|
||||||
wrapperProps?: any;
|
wrapperProps?: any;
|
||||||
@@ -195,6 +198,7 @@ export function DataTable<TData, TValue>({
|
|||||||
tableClassName,
|
tableClassName,
|
||||||
inset,
|
inset,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isFetching = false,
|
||||||
paginationClassName,
|
paginationClassName,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
wrapperClassName,
|
wrapperClassName,
|
||||||
@@ -211,6 +215,8 @@ export function DataTable<TData, TValue>({
|
|||||||
useRowId,
|
useRowId,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
showResetFilterButton = true,
|
showResetFilterButton = true,
|
||||||
|
serverSidePagination = false,
|
||||||
|
hasServerSideFilters,
|
||||||
onFilterReset,
|
onFilterReset,
|
||||||
showSearchAndFilters = true,
|
showSearchAndFilters = true,
|
||||||
wrapperProps,
|
wrapperProps,
|
||||||
@@ -236,6 +242,19 @@ export function DataTable<TData, TValue>({
|
|||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const isInitialRender = useRef(true);
|
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] =
|
const [localColumnFilters, setLocalColumnFilters] =
|
||||||
useLocalStorage<ColumnFiltersState>(
|
useLocalStorage<ColumnFiltersState>(
|
||||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||||
@@ -411,12 +430,7 @@ export function DataTable<TData, TValue>({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("relative table-fixed-scroll", className)}>
|
<div className={cn("relative table-fixed-scroll", className)}>
|
||||||
{showSearchAndFilters && (
|
{showSearchAndFilters && (
|
||||||
<div
|
<div className={cn("flex gap-x-4 gap-y-6", !minimal && "p-default")}>
|
||||||
className={cn(
|
|
||||||
"flex gap-x-4 gap-y-6 flex-wrap",
|
|
||||||
!minimal && "p-default",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DataTableGlobalSearch
|
<DataTableGlobalSearch
|
||||||
className={searchClassName}
|
className={searchClassName}
|
||||||
disabled={false} // Never disable the search input
|
disabled={false} // Never disable the search input
|
||||||
@@ -439,10 +453,14 @@ export function DataTable<TData, TValue>({
|
|||||||
/>
|
/>
|
||||||
{children?.(table)}
|
{children?.(table)}
|
||||||
{showResetFilterButton && (
|
{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 grow"}>
|
||||||
<div className={"flex gap-4 flex-wrap"}></div>
|
<div className={"flex gap-4"}></div>
|
||||||
{rightSide?.(table)}
|
{rightSide?.(table)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,13 +468,17 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
{aboveTable?.(table)}
|
{aboveTable?.(table)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{showOverlay && (
|
||||||
|
<div className="absolute inset-0 bg-nb-gray-950/60 z-10 rounded-md animate-pulse" />
|
||||||
|
)}
|
||||||
<TableWrapper
|
<TableWrapper
|
||||||
wrapperComponent={wrapperComponent}
|
wrapperComponent={wrapperComponent}
|
||||||
wrapperProps={wrapperProps}
|
wrapperProps={wrapperProps}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableContentSkeleton />
|
<TableContentSkeleton />
|
||||||
) : !hasInitialData ? (
|
) : !hasInitialData && !hasServerSideFilters ? (
|
||||||
getStartedCard
|
getStartedCard
|
||||||
) : (
|
) : (
|
||||||
<TableComponent
|
<TableComponent
|
||||||
@@ -509,7 +531,9 @@ export function DataTable<TData, TValue>({
|
|||||||
(onRowClick || renderExpandedRow) &&
|
(onRowClick || renderExpandedRow) &&
|
||||||
"relative group/accordion",
|
"relative group/accordion",
|
||||||
(onRowClick || expandedRow) && "cursor-pointer",
|
(onRowClick || expandedRow) && "cursor-pointer",
|
||||||
rowClassName,
|
typeof rowClassName === "function"
|
||||||
|
? rowClassName(row)
|
||||||
|
: rowClassName,
|
||||||
)}
|
)}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
data-accordion={isExpanded ? "opened" : "closed"}
|
data-accordion={isExpanded ? "opened" : "closed"}
|
||||||
@@ -519,9 +543,7 @@ export function DataTable<TData, TValue>({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setAccordion((prev) => {
|
setAccordion((prev) => {
|
||||||
if (prev?.includes(rowId)) {
|
if (prev?.includes(rowId)) {
|
||||||
return prev.filter(
|
return prev.filter((item) => item !== rowId);
|
||||||
(item) => item !== rowId,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return [...(prev ?? []), rowId];
|
return [...(prev ?? []), rowId];
|
||||||
}
|
}
|
||||||
@@ -560,7 +582,9 @@ export function DataTable<TData, TValue>({
|
|||||||
minimal={minimal}
|
minimal={minimal}
|
||||||
className={cn(
|
className={cn(
|
||||||
onRowClick && "cursor-pointer relative",
|
onRowClick && "cursor-pointer relative",
|
||||||
rowClassName,
|
typeof rowClassName === "function"
|
||||||
|
? rowClassName(row)
|
||||||
|
: rowClassName,
|
||||||
)}
|
)}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
>
|
>
|
||||||
@@ -593,6 +617,7 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableComponent>
|
</TableComponent>
|
||||||
)}
|
)}
|
||||||
</TableWrapper>
|
</TableWrapper>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={paginationClassName}>
|
<div className={paginationClassName}>
|
||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
@@ -603,7 +628,13 @@ export function DataTable<TData, TValue>({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTableHeadingPortal table={table} headingTarget={headingTarget} />
|
<DataTableHeadingPortal
|
||||||
|
table={table}
|
||||||
|
headingTarget={headingTarget}
|
||||||
|
totalRecords={totalRecords}
|
||||||
|
manualPagination={manualPagination}
|
||||||
|
hasActiveFilters={hasServerSideFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
tooltip?: string | React.ReactNode;
|
tooltip?: string | React.ReactNode;
|
||||||
center?: boolean;
|
center?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
sorting?: boolean;
|
||||||
};
|
};
|
||||||
export default function DataTableHeader({
|
export default function DataTableHeader({
|
||||||
children,
|
children,
|
||||||
@@ -19,23 +20,31 @@ export default function DataTableHeader({
|
|||||||
tooltip,
|
tooltip,
|
||||||
center,
|
center,
|
||||||
className,
|
className,
|
||||||
|
sorting = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<FullTooltip content={tooltip} disabled={!tooltip}>
|
<FullTooltip content={tooltip} disabled={!tooltip}>
|
||||||
<div
|
<div
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={
|
||||||
|
sorting
|
||||||
|
? () => column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={cn(
|
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",
|
center && "justify-center w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{column.getIsSorted() === "desc" ? (
|
{sorting &&
|
||||||
|
(column.getIsSorted() === "desc" ? (
|
||||||
<IconSortAscending size={16} />
|
<IconSortAscending size={16} />
|
||||||
) : (
|
) : (
|
||||||
<IconSortDescending size={16} />
|
<IconSortDescending size={16} />
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</FullTooltip>
|
</FullTooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,23 +6,53 @@ import { createPortal } from "react-dom";
|
|||||||
type Props<TData> = {
|
type Props<TData> = {
|
||||||
table: Table<TData> | null;
|
table: Table<TData> | null;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
headingTarget?: HTMLHeadingElement | null;
|
||||||
|
totalRecords?: number;
|
||||||
|
manualPagination?: boolean;
|
||||||
|
hasActiveFilters?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableHeadingPortal = function <TData>({
|
export const DataTableHeadingPortal = function <TData>({
|
||||||
table,
|
table,
|
||||||
headingTarget,
|
headingTarget,
|
||||||
|
totalRecords,
|
||||||
|
manualPagination,
|
||||||
|
hasActiveFilters,
|
||||||
}: Props<TData>) {
|
}: Props<TData>) {
|
||||||
const hasMounted = useRef(false);
|
const hasMounted = useRef(false);
|
||||||
|
const initialTotalRecords = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
if (
|
||||||
|
manualPagination &&
|
||||||
|
totalRecords !== undefined &&
|
||||||
|
initialTotalRecords.current === undefined
|
||||||
|
) {
|
||||||
|
initialTotalRecords.current = totalRecords;
|
||||||
|
}
|
||||||
|
|
||||||
if (!headingTarget) return;
|
if (!headingTarget) return;
|
||||||
if (!hasMounted.current) hasMounted.current = true;
|
if (!hasMounted.current) hasMounted.current = true;
|
||||||
|
|
||||||
const totalItems = table?.getPreFilteredRowModel().rows.length;
|
const filteredItems = manualPagination
|
||||||
const filteredItems = table?.getFilteredRowModel().rows.length;
|
? 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;
|
if (!totalItems || totalItems == 1) return;
|
||||||
|
|
||||||
const hasAnyFiltersActive =
|
const hasAnyFiltersActive = manualPagination
|
||||||
table &&
|
? hasActiveFilters ?? totalRecords !== initialTotalRecords.current
|
||||||
|
: table &&
|
||||||
!(
|
!(
|
||||||
table?.getState().columnFilters.length <= 0 &&
|
table?.getState().columnFilters.length <= 0 &&
|
||||||
table?.getState().globalFilter === ""
|
table?.getState().globalFilter === ""
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -64,7 +64,7 @@ export default function GetStartedTest({
|
|||||||
{description}
|
{description}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<div>{button}</div>
|
{button && <div>{button}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph className={"text-sm justify-center pb-5 px-8"}>
|
<Paragraph className={"text-sm justify-center pb-5 px-8"}>
|
||||||
|
|||||||
@@ -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 { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import Skeleton from "react-loading-skeleton";
|
import Skeleton from "react-loading-skeleton";
|
||||||
|
import SquareIcon from "@components/SquareIcon";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
@@ -54,7 +55,7 @@ export default function NoResults({
|
|||||||
<div className={cn("relative overflow-hidden", className)}>
|
<div className={cn("relative overflow-hidden", className)}>
|
||||||
<div
|
<div
|
||||||
className={
|
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>
|
||||||
<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} />
|
<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>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
|
className={cn("max-w-md mx-auto relative z-20 py-6", contentClassName)}
|
||||||
>
|
>
|
||||||
<div
|
<div className={"flex items-center justify-center mb-6"}>
|
||||||
className={
|
<SquareIcon
|
||||||
"mx-auto w-14 h-14 bg-nb-gray-930 flex items-center justify-center mb-3 rounded-md"
|
icon={icon ? icon : <FilterX size={24} />}
|
||||||
}
|
color={"gray"}
|
||||||
>
|
size={"large"}
|
||||||
{icon ? icon : <FilterX size={24} />}
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={"text-center"}>
|
<div className={"text-center"}>
|
||||||
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
|
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
|
||||||
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
|
<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,12 @@ const smallBadgeVariants = cva("", {
|
|||||||
white: "bg-white/20 border border-white/10 text-white",
|
white: "bg-white/20 border border-white/10 text-white",
|
||||||
sky: "bg-sky-900 border border-sky-500/20 text-white",
|
sky: "bg-sky-900 border border-sky-500/20 text-white",
|
||||||
netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300",
|
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,
|
textClassName,
|
||||||
variant = "green",
|
variant = "green",
|
||||||
children,
|
children,
|
||||||
|
size = "default",
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={cn(smallBadgeVariants({ variant, size }), className)}>
|
||||||
className={cn(
|
|
||||||
smallBadgeVariants({ variant }),
|
|
||||||
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
|
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,22 +4,38 @@ import React, { useMemo, useState } from "react";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
tooltipContent?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxChars?: number;
|
maxChars?: number;
|
||||||
maxWidth?: string; // Optional CSS width value
|
maxWidth?: string; // Optional CSS width value
|
||||||
hideTooltip?: boolean;
|
hideTooltip?: boolean;
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
alignOffset?: number;
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
sideOffset?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TruncatedText({
|
export default function TruncatedText({
|
||||||
text,
|
text,
|
||||||
|
children,
|
||||||
|
tooltipContent,
|
||||||
className,
|
className,
|
||||||
maxChars = 40,
|
maxChars = 40,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
hideTooltip = false,
|
hideTooltip = false,
|
||||||
|
align,
|
||||||
|
alignOffset = 20,
|
||||||
|
side,
|
||||||
|
sideOffset = 4,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const measureRef = React.useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const hasCustomChildren = !!children;
|
||||||
|
const content = children ?? text;
|
||||||
|
|
||||||
const charCount = useMemo(() => {
|
const charCount = useMemo(() => {
|
||||||
if (!text) return 0;
|
if (!text) return 0;
|
||||||
@@ -27,12 +43,17 @@ export default function TruncatedText({
|
|||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
// Check for overflow on mount and when text/maxWidth changes
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
const element = contentRef.current;
|
const container = contentRef.current;
|
||||||
if (element) {
|
const measure = measureRef.current;
|
||||||
setIsOverflowing(element.scrollWidth > element.clientWidth);
|
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
|
// If maxWidth is provided, use overflow detection
|
||||||
// Otherwise, fall back to character count logic
|
// Otherwise, fall back to character count logic
|
||||||
@@ -44,11 +65,28 @@ export default function TruncatedText({
|
|||||||
? { maxWidth }
|
? { maxWidth }
|
||||||
: { maxWidth: `${maxChars - 2}ch` };
|
: { 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) {
|
if (isDisabled) {
|
||||||
return (
|
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")}>
|
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||||
{text}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,25 +95,36 @@ export default function TruncatedText({
|
|||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
|
<Tooltip delayDuration={650} open={open} onOpenChange={setOpen}>
|
||||||
<TooltipTrigger asChild={true}>
|
<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")}>
|
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||||
{text}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
alignOffset={20}
|
align={align}
|
||||||
sideOffset={4}
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={cn(className, "px-3 py-1.5")}
|
className={cn(className, "px-3 py-1.5")}
|
||||||
>
|
>
|
||||||
|
{tooltipContent ?? (
|
||||||
<div className="text-neutral-300 flex flex-col gap-1">
|
<div className="text-neutral-300 flex flex-col gap-1">
|
||||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
634
src/contexts/ReverseProxiesProvider.tsx
Normal file
634
src/contexts/ReverseProxiesProvider.tsx
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
"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;
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
222
src/contexts/ServerPaginationProvider.tsx
Normal file
222
src/contexts/ServerPaginationProvider.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"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;
|
||||||
|
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 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,
|
||||||
|
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,
|
||||||
|
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>;
|
||||||
|
}
|
||||||
27
src/hooks/useUrlTab.ts
Normal file
27
src/hooks/useUrlTab.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
export default function useUrlTab(
|
||||||
|
validTabs: string[],
|
||||||
|
defaultTab: string,
|
||||||
|
): [string, (value: string) => void] {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const tab = useMemo(() => {
|
||||||
|
const tabParam = searchParams.get("tab");
|
||||||
|
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||||
|
return defaultTab;
|
||||||
|
}, [searchParams, validTabs, defaultTab]);
|
||||||
|
|
||||||
|
const setTab = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("tab", value);
|
||||||
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[searchParams, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [tab, setTab];
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export interface NetworkRouter {
|
|||||||
metric: number;
|
metric: number;
|
||||||
masquerade: boolean;
|
masquerade: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkResource {
|
export interface NetworkResource {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface Permissions {
|
|||||||
|
|
||||||
proxy: Permission;
|
proxy: Permission;
|
||||||
proxy_configuration: Permission;
|
proxy_configuration: Permission;
|
||||||
|
|
||||||
|
services: Permission;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
131
src/interfaces/ReverseProxy.ts
Normal file
131
src/interfaces/ReverseProxy.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
export interface ReverseProxy {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
proxy_cluster?: string;
|
||||||
|
targets: ReverseProxyTarget[];
|
||||||
|
enabled: boolean;
|
||||||
|
pass_host_header?: boolean;
|
||||||
|
rewrite_redirects?: boolean;
|
||||||
|
auth?: ReverseProxyAuth;
|
||||||
|
meta?: ReverseProxyMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 interface ReverseProxyTarget {
|
||||||
|
target_id?: string;
|
||||||
|
target_type: ReverseProxyTargetType;
|
||||||
|
path?: string;
|
||||||
|
protocol: ReverseProxyTargetProtocol;
|
||||||
|
host?: string;
|
||||||
|
port: number;
|
||||||
|
enabled: boolean;
|
||||||
|
access_local?: boolean;
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReverseProxyDomain {
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
validated: boolean;
|
||||||
|
type: ReverseProxyDomainType;
|
||||||
|
target_cluster?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
|
||||||
|
proxy: ReverseProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
@@ -9,7 +9,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
|||||||
import { Viewport } from "next";
|
import { Viewport } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "sonner";
|
||||||
import OIDCProvider from "@/auth/OIDCProvider";
|
import OIDCProvider from "@/auth/OIDCProvider";
|
||||||
import FullScreenLoading from "@/components/ui/FullScreenLoading";
|
import FullScreenLoading from "@/components/ui/FullScreenLoading";
|
||||||
import AnalyticsProvider, {
|
import AnalyticsProvider, {
|
||||||
@@ -59,10 +59,13 @@ export default function AppLayout({
|
|||||||
</GlobalThemeProvider>
|
</GlobalThemeProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
<Toaster
|
<Toaster
|
||||||
position={"top-center"}
|
position="top-center"
|
||||||
toastOptions={{
|
duration={3000}
|
||||||
duration: 3000,
|
toastOptions={{ unstyled: true }}
|
||||||
}}
|
style={{ "--width": "28rem" } as React.CSSProperties}
|
||||||
|
gap={0}
|
||||||
|
visibleToasts={5}
|
||||||
|
offset="12px"
|
||||||
/>
|
/>
|
||||||
<NavigationEvents />
|
<NavigationEvents />
|
||||||
<DisableDarkReader />
|
<DisableDarkReader />
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ScrollArea } from "@components/ScrollArea";
|
import { ScrollArea } from "@components/ScrollArea";
|
||||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import * as React from "react";
|
|
||||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
|
||||||
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
|
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
|
||||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||||
import DocsIcon from "@/assets/icons/DocsIcon";
|
import DocsIcon from "@/assets/icons/DocsIcon";
|
||||||
@@ -20,6 +17,10 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
|||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { headerHeight } from "@/layouts/Header";
|
import { headerHeight } from "@/layouts/Header";
|
||||||
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
|
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 = {
|
type Props = {
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
@@ -131,6 +132,41 @@ export default function Navigation({
|
|||||||
|
|
||||||
<NetworkNavigation />
|
<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
|
<SidebarItem
|
||||||
icon={<DNSIcon />}
|
icon={<DNSIcon />}
|
||||||
label="DNS"
|
label="DNS"
|
||||||
@@ -176,13 +212,7 @@ export default function Navigation({
|
|||||||
visible={permission.users.read}
|
visible={permission.users.read}
|
||||||
/>
|
/>
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<SidebarItem
|
<ActivityNavigationItem />
|
||||||
icon={<ActivityIcon />}
|
|
||||||
label="Activity"
|
|
||||||
href={"/events/audit"}
|
|
||||||
exactPathMatch={true}
|
|
||||||
visible={permission.events.read}
|
|
||||||
/>
|
|
||||||
</SidebarItemGroup>
|
</SidebarItemGroup>
|
||||||
|
|
||||||
<SidebarItemGroup>
|
<SidebarItemGroup>
|
||||||
@@ -225,3 +255,31 @@ export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -742,10 +742,16 @@ export default function ActivityDescription({ event }: Props) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
if (event.activity_code == "peer.job.create")
|
if (event.activity_code == "peer.job.create")
|
||||||
return (<div className={"inline"}>
|
return (
|
||||||
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
|
<div className={"inline"}>
|
||||||
|
Remote job <Value>{m.job_type}</Value> created for peer{" "}
|
||||||
|
<Value>{m.for_peer_name}</Value>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow Settings
|
||||||
|
*/
|
||||||
|
|
||||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const NetworkRoutingPeerCount = ({ network }: Props) => {
|
|||||||
}, [network, routerCount]);
|
}, [network, routerCount]);
|
||||||
|
|
||||||
const openNetworkPage = () => {
|
const openNetworkPage = () => {
|
||||||
router.push(`/network?id=${network.id}#routing-peers`);
|
router.push(`/network?id=${network.id}&tab=routing-peers`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 * as React from "react";
|
||||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
import { DeviceCard } from "@components/DeviceCard";
|
||||||
|
|
||||||
type NetworkNodeType = {
|
type NetworkNodeType = {
|
||||||
network: Network;
|
network: Network;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cn } from "@utils/helpers";
|
|||||||
import { Handle, type Node, Position } from "@xyflow/react";
|
import { Handle, type Node, Position } from "@xyflow/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { Peer } from "@/interfaces/Peer";
|
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";
|
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||||
|
|
||||||
type PeerNodeProps = Node<
|
type PeerNodeProps = Node<
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Handle, type Node, Position } from "@xyflow/react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { NetworkResource } from "@/interfaces/Network";
|
import { NetworkResource } from "@/interfaces/Network";
|
||||||
import { Peer } from "@/interfaces/Peer";
|
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";
|
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||||
|
|
||||||
type ResourceNode = Node<
|
type ResourceNode = Node<
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ChevronsUpDown } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||||
import type { Peer } from "@/interfaces/Peer";
|
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";
|
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||||
|
|
||||||
type PeerNodeProps = Node<
|
type PeerNodeProps = Node<
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import NetworkModal from "@/modules/networks/NetworkModal";
|
|||||||
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
|
import NetworkResourceModal from "@/modules/networks/resources/NetworkResourceModal";
|
||||||
import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
|
import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupModal";
|
||||||
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
|
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
|
||||||
import { Policy } from "@/interfaces/Policy";
|
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -59,6 +59,7 @@ export const NetworkProvider = ({
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
destinationGroups?: Group[] | string[];
|
destinationGroups?: Group[] | string[];
|
||||||
|
destinationResource?: PolicyRuleResource;
|
||||||
}>();
|
}>();
|
||||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||||
|
|
||||||
@@ -103,8 +104,17 @@ export const NetworkProvider = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openPolicyModal = (network?: Network, resource?: NetworkResource) => {
|
const openPolicyModal = (network?: Network, resource?: NetworkResource) => {
|
||||||
|
const hasResourceGroups = (resource?.groups?.length || 0) > 0;
|
||||||
setPolicyDefaultSettings({
|
setPolicyDefaultSettings({
|
||||||
destinationGroups: resource?.groups,
|
destinationGroups: hasResourceGroups ? resource?.groups : undefined,
|
||||||
|
destinationResource: hasResourceGroups
|
||||||
|
? undefined
|
||||||
|
: resource
|
||||||
|
? ({
|
||||||
|
id: resource.id,
|
||||||
|
type: resource.type,
|
||||||
|
} as PolicyRuleResource)
|
||||||
|
: undefined,
|
||||||
name:
|
name:
|
||||||
network && !resource
|
network && !resource
|
||||||
? `${network?.name} Policy`
|
? `${network?.name} Policy`
|
||||||
@@ -178,6 +188,7 @@ export const NetworkProvider = ({
|
|||||||
() => {
|
() => {
|
||||||
onResourceDelete?.();
|
onResourceDelete?.();
|
||||||
mutate(`/networks/${network.id}/resources`);
|
mutate(`/networks/${network.id}/resources`);
|
||||||
|
mutate(`/networks/${network.id}`);
|
||||||
mutate("/groups");
|
mutate("/groups");
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -289,6 +300,9 @@ export const NetworkProvider = ({
|
|||||||
<AccessControlModalContent
|
<AccessControlModalContent
|
||||||
key={policyModal ? "1" : "0"}
|
key={policyModal ? "1" : "0"}
|
||||||
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
|
initialDestinationGroups={policyDefaultSettings?.destinationGroups}
|
||||||
|
initialDestinationResource={
|
||||||
|
policyDefaultSettings?.destinationResource
|
||||||
|
}
|
||||||
initialName={policyDefaultSettings?.name}
|
initialName={policyDefaultSettings?.name}
|
||||||
initialDescription={policyDefaultSettings?.description}
|
initialDescription={policyDefaultSettings?.description}
|
||||||
policy={currentPolicy}
|
policy={currentPolicy}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const NetworkInformationSquare = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
|
"flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
|
||||||
onClick
|
onClick
|
||||||
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-14 relative"
|
? "hover:text-neutral-100 hover:bg-nb-gray-900/60 cursor-pointer py-2 pl-3 pr-14 relative"
|
||||||
: "cursor-default",
|
: "cursor-default",
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { notify } from "@components/Notification";
|
|||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||||
import Separator from "@components/Separator";
|
import Separator from "@components/Separator";
|
||||||
import { Textarea } from "@components/Textarea";
|
|
||||||
import { useApiCall } from "@utils/api";
|
import { useApiCall } from "@utils/api";
|
||||||
import {
|
import {
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
@@ -100,7 +99,7 @@ export function ResourceModalContent({
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
address,
|
address,
|
||||||
groups: savedGroups.map((g) => g.id),
|
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||||
enabled,
|
enabled,
|
||||||
}).then((r) => {
|
}).then((r) => {
|
||||||
onCreated?.(r);
|
onCreated?.(r);
|
||||||
@@ -118,7 +117,7 @@ export function ResourceModalContent({
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
address,
|
address,
|
||||||
groups: savedGroups.map((g) => g.id),
|
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||||
enabled,
|
enabled,
|
||||||
}).then((r) => {
|
}).then((r) => {
|
||||||
onUpdated?.(r);
|
onUpdated?.(r);
|
||||||
@@ -128,7 +127,7 @@ export function ResourceModalContent({
|
|||||||
|
|
||||||
// TODO: Address validation is missing for proper handling of submit button
|
// TODO: Address validation is missing for proper handling of submit button
|
||||||
const canCreate = useMemo(() => {
|
const canCreate = useMemo(() => {
|
||||||
return name.length > 0 && address.length > 0 && groups.length > 0;
|
return name.length > 0 && address.length > 0;
|
||||||
}, [name, address, groups]);
|
}, [name, address, groups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,10 +161,9 @@ export function ResourceModalContent({
|
|||||||
<HelpText>
|
<HelpText>
|
||||||
Write a short description to add more context to this resource.
|
Write a short description to add more context to this resource.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<Textarea
|
<Input
|
||||||
placeholder={"e.g., Production, Development"}
|
placeholder={"e.g., Production, Development"}
|
||||||
value={description}
|
value={description}
|
||||||
rows={1}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,14 +171,14 @@ export function ResourceModalContent({
|
|||||||
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Destination Groups</Label>
|
<Label>Destination Groups (optional)</Label>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Add this resource to groups and use them as destinations when
|
Add this resource to groups and use them as destinations when
|
||||||
creating policies
|
creating policies
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||||
</div>
|
</div>
|
||||||
<div className={"mt-3"}>
|
<div className={"mt-2 mb-2"}>
|
||||||
<FancyToggleSwitch
|
<FancyToggleSwitch
|
||||||
value={enabled}
|
value={enabled}
|
||||||
onChange={setEnabled}
|
onChange={setEnabled}
|
||||||
|
|||||||
76
src/modules/networks/resources/ResourceExposeServiceCell.tsx
Normal file
76
src/modules/networks/resources/ResourceExposeServiceCell.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import Button from "@components/Button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
import { NetworkResource } from "@/interfaces/Network";
|
||||||
|
import { isResourceTargetType } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||||
|
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||||
|
import Badge from "@components/Badge";
|
||||||
|
import { CirclePlusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource: NetworkResource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResourceExposeServiceCell = ({ resource }: Props) => {
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
const { openModal, reverseProxies } = useReverseProxies();
|
||||||
|
const { network } = useNetworksContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const servicesCount = useMemo(() => {
|
||||||
|
if (!reverseProxies) return 0;
|
||||||
|
return reverseProxies.filter((proxy) =>
|
||||||
|
proxy.targets.some(
|
||||||
|
(target) =>
|
||||||
|
isResourceTargetType(target.target_type) &&
|
||||||
|
target.target_id === resource.id,
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
}, [reverseProxies, resource.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"flex items-center gap-3"}>
|
||||||
|
{servicesCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant={"gray"}
|
||||||
|
useHover={false}
|
||||||
|
className={"select-none hover:bg-nb-gray-910 cursor-pointer"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!network?.id) return;
|
||||||
|
router.push(`/network?id=${network.id}&tab=services&target=${resource.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReverseProxyIcon size={14} className={"fill-green-500"} />
|
||||||
|
<div>
|
||||||
|
<span className={"font-medium text-xs"}>{servicesCount}</span>
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={"secondary"}
|
||||||
|
size={"xs"}
|
||||||
|
className={"!px-3"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openModal({
|
||||||
|
initialResource: resource,
|
||||||
|
initialNetwork: network,
|
||||||
|
onSuccess: () => {
|
||||||
|
if (!network?.id) return;
|
||||||
|
router.push(`/network?id=${network.id}&tab=services`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!permission.services?.create}
|
||||||
|
>
|
||||||
|
<CirclePlusIcon size={12} />
|
||||||
|
Expose Service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,7 +12,6 @@ import Separator from "@components/Separator";
|
|||||||
import { useApiCall } from "@utils/api";
|
import { useApiCall } from "@utils/api";
|
||||||
import { FolderGit2 } from "lucide-react";
|
import { FolderGit2 } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||||
|
|
||||||
@@ -78,10 +77,6 @@ const ResourceGroupModalContent = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const canSave = useMemo(() => {
|
|
||||||
return groups.length > 0;
|
|
||||||
}, [groups]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
@@ -107,11 +102,7 @@ const ResourceGroupModalContent = ({
|
|||||||
<Button variant={"secondary"}>Cancel</Button>
|
<Button variant={"secondary"}>Cancel</Button>
|
||||||
</ModalClose>
|
</ModalClose>
|
||||||
|
|
||||||
<Button
|
<Button variant={"primary"} onClick={updateResource}>
|
||||||
variant={"primary"}
|
|
||||||
onClick={updateResource}
|
|
||||||
disabled={!canSave}
|
|
||||||
>
|
|
||||||
Save Groups
|
Save Groups
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
size={"xs"}
|
size={"xs"}
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
className={"min-w-[100px]"}
|
|
||||||
disabled={!permission.networks.update}
|
disabled={!permission.networks.update}
|
||||||
onClick={() => openPolicyModal(network, resource)}
|
onClick={() => openPolicyModal(network, resource)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import Paragraph from "@components/Paragraph";
|
|
||||||
import SkeletonTable, {
|
|
||||||
SkeletonTableHeader,
|
|
||||||
} from "@components/skeletons/SkeletonTable";
|
|
||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
|
||||||
import useFetchApi from "@utils/api";
|
|
||||||
import * as React from "react";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
|
||||||
import ResourcesTable from "@/modules/networks/resources/ResourcesTable";
|
|
||||||
|
|
||||||
type ResourcesSectionProps = {
|
|
||||||
network: Network;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ResourcesSection = ({ network }: ResourcesSectionProps) => {
|
|
||||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
|
||||||
`/networks/${network.id}/resources`,
|
|
||||||
);
|
|
||||||
const { ref: headingRef, portalTarget } =
|
|
||||||
usePortalElement<HTMLHeadingElement>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={"py-7 px-8"}>
|
|
||||||
<div className={"max-w-6xl"}>
|
|
||||||
<div className={"flex justify-between items-center mb-6"}>
|
|
||||||
<div>
|
|
||||||
<h2 ref={headingRef}>Resources</h2>
|
|
||||||
<Paragraph>Add and manage resources for this network.</Paragraph>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div>
|
|
||||||
<SkeletonTableHeader className={"!p-0"} />
|
|
||||||
<div className={"mt-8 w-full"}>
|
|
||||||
<SkeletonTable withHeader={false} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ResourcesTable
|
|
||||||
isLoading={isLoading}
|
|
||||||
headingTarget={portalTarget}
|
|
||||||
resources={resources}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
55
src/modules/networks/resources/ResourcesTabContent.tsx
Normal file
55
src/modules/networks/resources/ResourcesTabContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import SkeletonTable, {
|
||||||
|
SkeletonTableHeader,
|
||||||
|
} from "@components/skeletons/SkeletonTable";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { NetworkResource } from "@/interfaces/Network";
|
||||||
|
import ResourcesTable from "@/modules/networks/resources/ResourcesTable";
|
||||||
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type ResourcesSectionProps = {
|
||||||
|
data?: NetworkResource[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResourcesTabContent = ({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
}: ResourcesSectionProps) => {
|
||||||
|
return (
|
||||||
|
<div className={"px-8"}>
|
||||||
|
<div className={"flex justify-between items-center mb-5"}>
|
||||||
|
<div>
|
||||||
|
<Paragraph>
|
||||||
|
Add resources to this network to control what peers can access.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
Learn more about{" "}
|
||||||
|
<InlineLink
|
||||||
|
href={"https://docs.netbird.io/how-to/networks#resources"}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Resources
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
in our documentation.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div>
|
||||||
|
<SkeletonTableHeader className={"!p-0"} />
|
||||||
|
<div className={"mt-8 w-full"}>
|
||||||
|
<SkeletonTable withHeader={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourcesTable isLoading={isLoading} resources={data} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ import { Group } from "@/interfaces/Group";
|
|||||||
import { NetworkResource } from "@/interfaces/Network";
|
import { NetworkResource } from "@/interfaces/Network";
|
||||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||||
import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell";
|
import { ResourceActionCell } from "@/modules/networks/resources/ResourceActionCell";
|
||||||
|
import { ResourceExposeServiceCell } from "@/modules/networks/resources/ResourceExposeServiceCell";
|
||||||
import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell";
|
import ResourceAddressCell from "@/modules/networks/resources/ResourceAddressCell";
|
||||||
import { ResourceEnabledCell } from "@/modules/networks/resources/ResourceEnabledCell";
|
import { ResourceEnabledCell } from "@/modules/networks/resources/ResourceEnabledCell";
|
||||||
import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell";
|
import { ResourceGroupCell } from "@/modules/networks/resources/ResourceGroupCell";
|
||||||
@@ -72,7 +73,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
|||||||
{
|
{
|
||||||
id: "groups",
|
id: "groups",
|
||||||
accessorFn: (resource) => {
|
accessorFn: (resource) => {
|
||||||
let groups = resource?.groups as Group[];
|
let groups = (resource?.groups ?? []) as Group[];
|
||||||
return groups.map((group) => group.name).join(", ");
|
return groups.map((group) => group.name).join(", ");
|
||||||
},
|
},
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
@@ -92,6 +93,14 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
|||||||
return <ResourcePolicyCell resource={row.original} />;
|
return <ResourcePolicyCell resource={row.original} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "expose_service",
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <ResourceExposeServiceCell resource={row.original} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
@@ -116,6 +125,13 @@ export default function ResourcesTable({
|
|||||||
const { openResourceModal, network } = useNetworksContext();
|
const { openResourceModal, network } = useNetworksContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const removeResourceParam = React.useCallback(() => {
|
||||||
|
if (!resourceId) return;
|
||||||
|
const newParams = new URLSearchParams(params.toString());
|
||||||
|
newParams.delete("resource");
|
||||||
|
router.replace(`?${newParams.toString()}`, { scroll: false });
|
||||||
|
}, [resourceId, params, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
wrapperComponent={Card}
|
wrapperComponent={Card}
|
||||||
@@ -134,6 +150,7 @@ export default function ResourcesTable({
|
|||||||
resourceId ? [{ id: "id", value: resourceId }] : undefined
|
resourceId ? [{ id: "id", value: resourceId }] : undefined
|
||||||
}
|
}
|
||||||
initialSearch={resourceId}
|
initialSearch={resourceId}
|
||||||
|
onFilterReset={removeResourceParam}
|
||||||
data={resources}
|
data={resources}
|
||||||
searchPlaceholder={"Search by name, address or group..."}
|
searchPlaceholder={"Search by name, address or group..."}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -150,7 +167,7 @@ export default function ResourcesTable({
|
|||||||
? "Assign this group to your resources inside your networks to see them listed here."
|
? "Assign this group to your resources inside your networks to see them listed here."
|
||||||
: "Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
|
: "Add resources to this network to control what peers can access. Resources can be anything from a single IP, a subnet, or a domain."
|
||||||
}
|
}
|
||||||
icon={<Layers3Icon size={20} />}
|
icon={<Layers3Icon size={20} className={"text-nb-gray-400"} />}
|
||||||
>
|
>
|
||||||
{isGroupPage && permission?.networks?.create && (
|
{isGroupPage && permission?.networks?.create && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import Button from "@components/Button";
|
|
||||||
import Paragraph from "@components/Paragraph";
|
|
||||||
import SkeletonTable, {
|
|
||||||
SkeletonTableHeader,
|
|
||||||
} from "@components/skeletons/SkeletonTable";
|
|
||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
|
||||||
import { IconCirclePlus } from "@tabler/icons-react";
|
|
||||||
import useFetchApi from "@utils/api";
|
|
||||||
import * as React from "react";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
|
||||||
import { Network, NetworkRouter } from "@/interfaces/Network";
|
|
||||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
|
||||||
import NetworkRoutingPeersTable from "@/modules/networks/routing-peers/NetworkRoutingPeersTable";
|
|
||||||
|
|
||||||
export const NetworkRoutingPeersSection = ({
|
|
||||||
network,
|
|
||||||
}: {
|
|
||||||
network: Network;
|
|
||||||
}) => {
|
|
||||||
const { permission } = usePermissions();
|
|
||||||
const { data: routers, isLoading } = useFetchApi<NetworkRouter[]>(
|
|
||||||
`/networks/${network.id}/routers`,
|
|
||||||
);
|
|
||||||
const { ref: headingRef, portalTarget } =
|
|
||||||
usePortalElement<HTMLHeadingElement>();
|
|
||||||
|
|
||||||
const { openAddRoutingPeerModal } = useNetworksContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={"py-7 px-8"} id={"routing-peers"}>
|
|
||||||
<div className={"max-w-6xl"}>
|
|
||||||
<div className={"flex justify-between items-center"}>
|
|
||||||
<div>
|
|
||||||
<h2 ref={headingRef}>Routing Peers</h2>
|
|
||||||
<Paragraph>
|
|
||||||
Add and manage routing peers for this network.
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
<div className={"inline-flex gap-4 justify-end"}>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant={"primary"}
|
|
||||||
onClick={() => openAddRoutingPeerModal(network)}
|
|
||||||
disabled={!permission.networks.update}
|
|
||||||
>
|
|
||||||
<IconCirclePlus size={16} />
|
|
||||||
Add Routing Peer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div>
|
|
||||||
<SkeletonTableHeader className={"!p-0"} />
|
|
||||||
<div className={"mt-8 w-full"}>
|
|
||||||
<SkeletonTable withHeader={false} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NetworkRoutingPeersTable
|
|
||||||
isLoading={isLoading}
|
|
||||||
routers={routers}
|
|
||||||
headingTarget={portalTarget}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SkeletonTable, {
|
||||||
|
SkeletonTableHeader,
|
||||||
|
} from "@components/skeletons/SkeletonTable";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Suspense, useMemo } from "react";
|
||||||
|
import { NetworkRouter } from "@/interfaces/Network";
|
||||||
|
import NetworkRoutingPeersTable from "@/modules/networks/routing-peers/NetworkRoutingPeersTable";
|
||||||
|
import useFetchApi from "@utils/api";
|
||||||
|
import { useGroups } from "@/contexts/GroupsProvider";
|
||||||
|
import { Peer } from "@/interfaces/Peer";
|
||||||
|
import { useUsers } from "@/contexts/UsersProvider";
|
||||||
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export const NetworkRoutingPeersTabContent = ({
|
||||||
|
routers,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
routers?: NetworkRouter[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}) => {
|
||||||
|
const { groups } = useGroups();
|
||||||
|
const { users } = useUsers();
|
||||||
|
const { data: peers } = useFetchApi<Peer[]>(`/peers`);
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
return routers?.map((router) => {
|
||||||
|
const peer = peers?.find((peer) => peer.id === router.peer);
|
||||||
|
const user = peer ? users?.find((user) => user.id === peer.user_id) : undefined;
|
||||||
|
const group = groups?.find(
|
||||||
|
(group) => group.id === router?.peer_groups?.[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...router,
|
||||||
|
search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [users, peers, routers, groups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"px-8"} id={"routing-peers"}>
|
||||||
|
<div className={"flex justify-between items-center mb-5"}>
|
||||||
|
<div>
|
||||||
|
<Paragraph>
|
||||||
|
Add routing peers to this network to access resources inside this
|
||||||
|
network.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={"https://docs.netbird.io/manage/networks#routing-peers"}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Routing Peers
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
in our documentation.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div>
|
||||||
|
<SkeletonTableHeader className={"!p-0"} />
|
||||||
|
<div className={"mt-8 w-full"}>
|
||||||
|
<SkeletonTable withHeader={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NetworkRoutingPeersTable isLoading={isLoading} routers={data} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -68,6 +68,12 @@ const NetworkRouterColumns: ColumnDef<NetworkRouter>[] = [
|
|||||||
return <RoutingPeersActionCell router={row.original} />;
|
return <RoutingPeersActionCell router={row.original} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "search",
|
||||||
|
accessorKey: "search",
|
||||||
|
header: "",
|
||||||
|
filterFn: "fuzzy",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function NetworkRoutingPeersTable({
|
export default function NetworkRoutingPeersTable({
|
||||||
@@ -93,14 +99,14 @@ export default function NetworkRoutingPeersTable({
|
|||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
minimal={true}
|
minimal={true}
|
||||||
showSearchAndFilters={false}
|
showSearchAndFilters={true}
|
||||||
inset={false}
|
inset={false}
|
||||||
tableClassName={"mt-0"}
|
tableClassName={"mt-0"}
|
||||||
text={"Routing Peers"}
|
text={"Routing Peers"}
|
||||||
columns={NetworkRouterColumns}
|
columns={NetworkRouterColumns}
|
||||||
keepStateInLocalStorage={false}
|
keepStateInLocalStorage={false}
|
||||||
data={routers}
|
data={routers}
|
||||||
searchPlaceholder={"Search by name..."}
|
searchPlaceholder={"Search by peer name, group name..."}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
<NoResults
|
<NoResults
|
||||||
@@ -109,10 +115,10 @@ export default function NetworkRoutingPeersTable({
|
|||||||
description={
|
description={
|
||||||
"Add routing peers to this network to access resources inside this network."
|
"Add routing peers to this network to access resources inside this network."
|
||||||
}
|
}
|
||||||
icon={<PeerIcon size={20} className={"fill-nb-gray-300"} />}
|
icon={<PeerIcon size={18} className={"fill-nb-gray-400"} />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
columnVisibility={{}}
|
columnVisibility={{ search: false }}
|
||||||
paginationPaddingClassName={"px-0 pt-8"}
|
paginationPaddingClassName={"px-0 pt-8"}
|
||||||
rightSide={() => (
|
rightSide={() => (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function NetworkRoutingPeerCell({ network }: Props) {
|
|||||||
"inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px] cursor-pointer",
|
"inline-flex gap-2 min-w-[110px] font-medium items-center justify-center min-h-[34px] cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/network?id=${network.id}#routing-peers`)
|
router.push(`/network?id=${network.id}&tab=routing-peers`)
|
||||||
}
|
}
|
||||||
useHover={true}
|
useHover={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Modal, ModalPortal } from "@components/modal/Modal";
|
|||||||
import { NetBirdLogo } from "@components/NetBirdLogo";
|
import { NetBirdLogo } from "@components/NetBirdLogo";
|
||||||
import { notify } from "@components/Notification";
|
import { notify } from "@components/Notification";
|
||||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||||
import { DialogContent } from "@radix-ui/react-dialog";
|
import { DialogContent, DialogTitle } from "@radix-ui/react-dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import useFetchApi, { useApiCall } from "@utils/api";
|
import useFetchApi, { useApiCall } from "@utils/api";
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import { isNetBirdHosted } from "@utils/netbird";
|
import { isNetBirdHosted } from "@utils/netbird";
|
||||||
@@ -304,6 +305,9 @@ export const Onboarding = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
<VisuallyHidden asChild>
|
||||||
|
<DialogTitle>Onboarding</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"sm:px-4 py-10 max-w-6xl mx-auto flex flex-col items-center",
|
"sm:px-4 py-10 max-w-6xl mx-auto flex flex-col items-center",
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import TruncatedText from "@components/ui/TruncatedText";
|
import { DeviceCard } from "@components/DeviceCard";
|
||||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import {
|
import { ShieldCheckIcon, ShieldXIcon } from "lucide-react";
|
||||||
GlobeIcon,
|
|
||||||
NetworkIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
ShieldXIcon,
|
|
||||||
WorkflowIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
|
||||||
import { NetworkResource } from "@/interfaces/Network";
|
import { NetworkResource } from "@/interfaces/Network";
|
||||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
|
||||||
import type { Peer } from "@/interfaces/Peer";
|
import type { Peer } from "@/interfaces/Peer";
|
||||||
import { Intent } from "@/modules/onboarding/Onboarding";
|
import { Intent } from "@/modules/onboarding/Onboarding";
|
||||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
intent?: Intent;
|
intent?: Intent;
|
||||||
@@ -169,101 +159,6 @@ export const WaitingForDevice = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeviceCardProps = {
|
|
||||||
device?: Peer;
|
|
||||||
resource?: NetworkResource;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DeviceCard = ({ device, resource }: 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]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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} />}
|
|
||||||
|
|
||||||
{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} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Line = ({
|
const Line = ({
|
||||||
className,
|
className,
|
||||||
height = "100%",
|
height = "100%",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Paragraph from "@components/Paragraph";
|
|||||||
import SkeletonTable, {
|
import SkeletonTable, {
|
||||||
SkeletonTableHeader,
|
SkeletonTableHeader,
|
||||||
} from "@components/skeletons/SkeletonTable";
|
} from "@components/skeletons/SkeletonTable";
|
||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
@@ -21,8 +20,6 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
|
|||||||
`/peers/${peerID}/accessible-peers`,
|
`/peers/${peerID}/accessible-peers`,
|
||||||
);
|
);
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { ref: headingRef, portalTarget } =
|
|
||||||
usePortalElement<HTMLHeadingElement>();
|
|
||||||
|
|
||||||
const peersWithUser = peers?.map((peer) => {
|
const peersWithUser = peers?.map((peer) => {
|
||||||
if (!users) return peer;
|
if (!users) return peer;
|
||||||
@@ -34,10 +31,9 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"pb-10 px-8"}>
|
<div className={"pb-10 px-8"}>
|
||||||
<div className={"max-w-6xl"}>
|
<div className={""}>
|
||||||
<div className={"flex justify-between items-center mb-5"}>
|
<div className={"flex justify-between items-center mb-5"}>
|
||||||
<div>
|
<div>
|
||||||
<h2 ref={headingRef}>Accessible Peers</h2>
|
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
This peer can connect to the following peers within the NetBird
|
This peer can connect to the following peers within the NetBird
|
||||||
network.
|
network.
|
||||||
@@ -59,7 +55,6 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
|
|||||||
peerID={peerID}
|
peerID={peerID}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
peers={peersWithUser}
|
peers={peersWithUser}
|
||||||
headingTarget={portalTarget}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import type { Peer } from "@/interfaces/Peer";
|
import type { Peer } from "@/interfaces/Peer";
|
||||||
@@ -8,6 +7,8 @@ import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
|
|||||||
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
|
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
|
||||||
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
|
import AddRouteDropdownButton from "@/modules/peer/AddRouteDropdownButton";
|
||||||
import usePeerRoutes from "@/modules/peer/usePeerRoutes";
|
import usePeerRoutes from "@/modules/peer/usePeerRoutes";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
const PeerRoutesTable = lazy(() => import("@/modules/peer/PeerRoutesTable"));
|
const PeerRoutesTable = lazy(() => import("@/modules/peer/PeerRoutesTable"));
|
||||||
|
|
||||||
@@ -18,23 +19,36 @@ type Props = {
|
|||||||
export const PeerNetworkRoutesSection = ({ peer }: Props) => {
|
export const PeerNetworkRoutesSection = ({ peer }: Props) => {
|
||||||
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
|
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
|
||||||
const exitNodeInfo = useHasExitNodes(peer);
|
const exitNodeInfo = useHasExitNodes(peer);
|
||||||
const { ref: headingRef, portalTarget } =
|
|
||||||
usePortalElement<HTMLHeadingElement>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"pb-10 px-8"}>
|
<div className={"pb-10 px-8"}>
|
||||||
<div className={"max-w-6xl"}>
|
<div className={""}>
|
||||||
<div className={"flex justify-between items-center mb-5"}>
|
<div className={"flex justify-between items-center mb-5"}>
|
||||||
<div>
|
<div>
|
||||||
<h2 ref={headingRef}>Network Routes</h2>
|
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
Access other networks without installing NetBird on every
|
Access other networks without installing NetBird on every
|
||||||
resource.
|
resource.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={
|
||||||
|
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
|
||||||
|
}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Network Routes
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
in our documentation.
|
||||||
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<div className={"inline-flex gap-4 justify-end"}>
|
<div className={"inline-flex gap-4 justify-end"}>
|
||||||
<div className={"gap-4 flex"}>
|
<div className={"gap-4 flex"}>
|
||||||
<AddExitNodeButton peer={peer} firstTime={!exitNodeInfo.hasExitNode} />
|
<AddExitNodeButton
|
||||||
|
peer={peer}
|
||||||
|
firstTime={!exitNodeInfo.hasExitNode}
|
||||||
|
/>
|
||||||
<AddRouteDropdownButton />
|
<AddRouteDropdownButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +67,6 @@ export const PeerNetworkRoutesSection = ({ peer }: Props) => {
|
|||||||
peer={peer}
|
peer={peer}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
peerRoutes={peerRoutes}
|
peerRoutes={peerRoutes}
|
||||||
headingTarget={portalTarget}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Paragraph from "@/components/Paragraph";
|
|||||||
import SkeletonTable, {
|
import SkeletonTable, {
|
||||||
SkeletonTableHeader,
|
SkeletonTableHeader,
|
||||||
} from "@/components/skeletons/SkeletonTable";
|
} from "@/components/skeletons/SkeletonTable";
|
||||||
import { usePortalElement } from "@/hooks/usePortalElement";
|
|
||||||
import { Job } from "@/interfaces/Job";
|
import { Job } from "@/interfaces/Job";
|
||||||
import useFetchApi from "@/utils/api";
|
import useFetchApi from "@/utils/api";
|
||||||
|
|
||||||
@@ -18,15 +17,12 @@ type Props = {
|
|||||||
|
|
||||||
export const PeerRemoteJobsSection = ({ peerID }: Props) => {
|
export const PeerRemoteJobsSection = ({ peerID }: Props) => {
|
||||||
const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`);
|
const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`);
|
||||||
const { ref: headingRef, portalTarget } =
|
|
||||||
usePortalElement<HTMLHeadingElement>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10 px-8">
|
<div className="pb-10 px-8">
|
||||||
<div className="max-w-6xl">
|
<div className="">
|
||||||
<div className="flex justify-between items-center mb-5">
|
<div className="flex justify-between items-center mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 ref={headingRef}>Remote Jobs</h2>
|
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
Remotely trigger actions such as debug bundles or other tasks on
|
Remotely trigger actions such as debug bundles or other tasks on
|
||||||
this peer, without requiring CLI access.
|
this peer, without requiring CLI access.
|
||||||
@@ -55,7 +51,6 @@ export const PeerRemoteJobsSection = ({ peerID }: Props) => {
|
|||||||
peerID={peerID}
|
peerID={peerID}
|
||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
headingTarget={portalTarget}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import RouteDistributionGroupsCell from "@/modules/routes/RouteDistributionGroup
|
|||||||
type Props = {
|
type Props = {
|
||||||
peerRoutes?: Route[];
|
peerRoutes?: Route[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
headingTarget?: HTMLHeadingElement | null;
|
|
||||||
peer: Peer;
|
peer: Peer;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +71,6 @@ export default function PeerRoutesTable({
|
|||||||
peerRoutes,
|
peerRoutes,
|
||||||
isLoading,
|
isLoading,
|
||||||
peer,
|
peer,
|
||||||
headingTarget,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Default sorting state of the table
|
// Default sorting state of the table
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
@@ -89,7 +87,6 @@ export default function PeerRoutesTable({
|
|||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
className: cn("w-full"),
|
className: cn("w-full"),
|
||||||
}}
|
}}
|
||||||
headingTarget={headingTarget}
|
|
||||||
text={"Network Routes"}
|
text={"Network Routes"}
|
||||||
tableClassName={"mt-0"}
|
tableClassName={"mt-0"}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
"flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
||||||
linkToPeer &&
|
linkToPeer &&
|
||||||
"hover:text-neutral-100 hover:bg-nb-gray-800/60 cursor-pointer",
|
"hover:text-neutral-100 hover:bg-nb-gray-900/60 cursor-pointer",
|
||||||
)}
|
)}
|
||||||
data-testid="peer-name-cell"
|
data-testid="peer-name-cell"
|
||||||
aria-label={`View details of peer ${peer.name}`}
|
aria-label={`View details of peer ${peer.name}`}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ type Props = {
|
|||||||
serial?: string;
|
serial?: string;
|
||||||
ephemeral?: boolean;
|
ephemeral?: boolean;
|
||||||
};
|
};
|
||||||
export default function PeerVersionCell({ version, os, serial, ephemeral }: Props) {
|
export default function PeerVersionCell({
|
||||||
|
version,
|
||||||
|
os,
|
||||||
|
serial,
|
||||||
|
ephemeral,
|
||||||
|
}: Props) {
|
||||||
const { latestVersion, latestUrl } = useApplicationContext();
|
const { latestVersion, latestUrl } = useApplicationContext();
|
||||||
|
|
||||||
const updateAvailable = useMemo(() => {
|
const updateAvailable = useMemo(() => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import PeerNameCell from "@/modules/peers/PeerNameCell";
|
|||||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||||
import PeerStatusCell from "@/modules/peers/PeerStatusCell";
|
import PeerStatusCell from "@/modules/peers/PeerStatusCell";
|
||||||
import PeerVersionCell from "@/modules/peers/PeerVersionCell";
|
import PeerVersionCell from "@/modules/peers/PeerVersionCell";
|
||||||
|
import { removeAllSpaces } from "@utils/helpers";
|
||||||
|
|
||||||
const PeersTableColumns: ColumnDef<Peer>[] = [
|
const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||||
{
|
{
|
||||||
@@ -144,7 +145,8 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
|||||||
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "os",
|
id: "os",
|
||||||
|
accessorFn: (peer) => removeAllSpaces(peer?.os),
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
return <DataTableHeader column={column}>OS</DataTableHeader>;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ export const usePostureCheck = ({ postureCheck, onSuccess }: Props = {}) => {
|
|||||||
checkToUpdateOrCreate?: PostureCheck,
|
checkToUpdateOrCreate?: PostureCheck,
|
||||||
) => {
|
) => {
|
||||||
const call = () => updateOrCreate(checkToUpdateOrCreate || state);
|
const call = () => updateOrCreate(checkToUpdateOrCreate || state);
|
||||||
let response = undefined;
|
const promise = call().then((check) => {
|
||||||
|
mutate("/posture-checks");
|
||||||
|
onSuccess && onSuccess(check);
|
||||||
|
return check;
|
||||||
|
});
|
||||||
notify({
|
notify({
|
||||||
title: `Posture Check ${state.name}`,
|
title: `Posture Check ${state.name}`,
|
||||||
description: `Posture Check was ${
|
description: `Posture Check was ${
|
||||||
@@ -127,17 +131,9 @@ export const usePostureCheck = ({ postureCheck, onSuccess }: Props = {}) => {
|
|||||||
loadingMessage: `${
|
loadingMessage: `${
|
||||||
postureCheck ? "Updating" : "Creating"
|
postureCheck ? "Updating" : "Creating"
|
||||||
} your posture check...`,
|
} your posture check...`,
|
||||||
promise: call().then((check) => {
|
promise,
|
||||||
mutate("/posture-checks");
|
|
||||||
onSuccess && onSuccess(check);
|
|
||||||
response = check;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (response === undefined) {
|
return promise;
|
||||||
return Promise.reject("Failed to create or update posture check");
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(response);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { state, dispatch, updateOrCreateAndNotify, updateOrCreate };
|
return { state, dispatch, updateOrCreateAndNotify, updateOrCreate };
|
||||||
|
|||||||
838
src/modules/reverse-proxy/ReverseProxyModal.tsx
Normal file
838
src/modules/reverse-proxy/ReverseProxyModal.tsx
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Button from "@components/Button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@components/DropdownMenu";
|
||||||
|
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||||
|
import HelpText from "@components/HelpText";
|
||||||
|
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
||||||
|
import { Input } from "@components/Input";
|
||||||
|
import { Label } from "@components/Label";
|
||||||
|
import SettingCard from "@components/SettingCard";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalClose,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
} from "@components/modal/Modal";
|
||||||
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||||
|
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
Binary,
|
||||||
|
Edit,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LockKeyhole,
|
||||||
|
MinusCircleIcon,
|
||||||
|
MoreVertical,
|
||||||
|
PlusCircle,
|
||||||
|
PlusIcon,
|
||||||
|
RectangleEllipsis,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
Text,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Callout } from "@components/Callout";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
|
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||||
|
import { Peer } from "@/interfaces/Peer";
|
||||||
|
import {
|
||||||
|
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||||
|
REVERSE_PROXY_SERVICES_DOCS_LINK,
|
||||||
|
REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||||
|
ReverseProxy,
|
||||||
|
ReverseProxyAuth,
|
||||||
|
ReverseProxyDomain,
|
||||||
|
ReverseProxyDomainType,
|
||||||
|
ReverseProxyTarget,
|
||||||
|
} from "@/interfaces/ReverseProxy";
|
||||||
|
import { CustomDomainSelector } from "./domain/CustomDomainSelector";
|
||||||
|
import { cn } from "@utils/helpers";
|
||||||
|
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
|
||||||
|
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
|
||||||
|
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
|
||||||
|
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
|
||||||
|
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||||
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
reverseProxy?: ReverseProxy;
|
||||||
|
domains?: ReverseProxyDomain[];
|
||||||
|
/** Pre-set the subdomain (e.g. from resource name) */
|
||||||
|
initialSubdomain?: string;
|
||||||
|
/** Pre-set a resource target - hides target selection in modal */
|
||||||
|
initialResource?: NetworkResource;
|
||||||
|
initialPeer?: Peer;
|
||||||
|
initialNetwork?: Network;
|
||||||
|
initialTab?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to parse domain into subdomain and base domain
|
||||||
|
function parseDomain(fullDomain: string): {
|
||||||
|
subdomain: string;
|
||||||
|
baseDomain: string;
|
||||||
|
isCustom: boolean;
|
||||||
|
} {
|
||||||
|
const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"];
|
||||||
|
|
||||||
|
for (const known of knownDomains) {
|
||||||
|
if (fullDomain.endsWith(`.${known}`)) {
|
||||||
|
return {
|
||||||
|
subdomain: fullDomain.slice(0, -(known.length + 1)),
|
||||||
|
baseDomain: known,
|
||||||
|
isCustom: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom domain - find the first dot to split
|
||||||
|
const firstDot = fullDomain.indexOf(".");
|
||||||
|
if (firstDot > 0) {
|
||||||
|
return {
|
||||||
|
subdomain: fullDomain.slice(0, firstDot),
|
||||||
|
baseDomain: fullDomain.slice(firstDot + 1),
|
||||||
|
isCustom: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subdomain: fullDomain,
|
||||||
|
baseDomain: "netbird.cloud",
|
||||||
|
isCustom: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReverseProxyModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
reverseProxy,
|
||||||
|
domains,
|
||||||
|
initialSubdomain,
|
||||||
|
initialResource,
|
||||||
|
initialPeer,
|
||||||
|
initialNetwork,
|
||||||
|
initialTab,
|
||||||
|
onSuccess,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
const { confirm } = useDialog();
|
||||||
|
const { reverseProxies, handleCreateOrUpdateProxy } = useReverseProxies();
|
||||||
|
|
||||||
|
// Check if the proxy's cluster exists in available free domains
|
||||||
|
const isClusterConnected = useMemo(() => {
|
||||||
|
if (!reverseProxy?.proxy_cluster) return false;
|
||||||
|
return domains?.some(
|
||||||
|
(d) =>
|
||||||
|
d.type === ReverseProxyDomainType.FREE &&
|
||||||
|
d.domain === reverseProxy.proxy_cluster,
|
||||||
|
);
|
||||||
|
}, [reverseProxy?.proxy_cluster, domains]);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState(() => {
|
||||||
|
if (initialTab && initialTab !== "") return initialTab;
|
||||||
|
return "targets";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse existing domain if editing
|
||||||
|
const parsed = reverseProxy?.domain ? parseDomain(reverseProxy.domain) : null;
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [subdomain, setSubdomain] = useState(
|
||||||
|
parsed?.subdomain ||
|
||||||
|
initialSubdomain
|
||||||
|
?.toLowerCase()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]/g, "") ||
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [baseDomain, setBaseDomain] = useState(() => {
|
||||||
|
if (parsed?.baseDomain) return parsed.baseDomain;
|
||||||
|
const validatedDomains = domains?.filter((d) => d.validated) || [];
|
||||||
|
const customDomain = validatedDomains.find(
|
||||||
|
(d) => d.type === ReverseProxyDomainType.CUSTOM,
|
||||||
|
);
|
||||||
|
const freeDomain = validatedDomains.find(
|
||||||
|
(d) => d.type === ReverseProxyDomainType.FREE,
|
||||||
|
);
|
||||||
|
return customDomain?.domain || freeDomain?.domain || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [targets, setTargets] = useState<ReverseProxyTarget[]>(
|
||||||
|
reverseProxy?.targets || [],
|
||||||
|
);
|
||||||
|
const [passHostHeader, setPassHostHeader] = useState(
|
||||||
|
reverseProxy?.pass_host_header ?? false,
|
||||||
|
);
|
||||||
|
const [rewriteRedirects, setRewriteRedirects] = useState(
|
||||||
|
reverseProxy?.rewrite_redirects ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute full domain
|
||||||
|
const fullDomain = useMemo(() => {
|
||||||
|
if (!baseDomain) return subdomain;
|
||||||
|
return `${subdomain}.${baseDomain}`;
|
||||||
|
}, [subdomain, baseDomain]);
|
||||||
|
|
||||||
|
const domainAlreadyExists = useMemo(() => {
|
||||||
|
if (!reverseProxies || !fullDomain) return false;
|
||||||
|
return reverseProxies.some(
|
||||||
|
(p) => p.domain === fullDomain && p.id !== reverseProxy?.id,
|
||||||
|
);
|
||||||
|
}, [reverseProxies, fullDomain, reverseProxy?.id]);
|
||||||
|
|
||||||
|
// Authentication options - initialized from existing reverseProxy.auth
|
||||||
|
const [passwordEnabled, setPasswordEnabled] = useState(
|
||||||
|
reverseProxy?.auth?.password_auth?.enabled ?? false,
|
||||||
|
);
|
||||||
|
const [password, setPassword] = useState(
|
||||||
|
reverseProxy?.auth?.password_auth?.password ?? "",
|
||||||
|
);
|
||||||
|
const [pinEnabled, setPinEnabled] = useState(
|
||||||
|
reverseProxy?.auth?.pin_auth?.enabled ?? false,
|
||||||
|
);
|
||||||
|
const [pin, setPin] = useState(reverseProxy?.auth?.pin_auth?.pin ?? "");
|
||||||
|
const [bearerEnabled, setBearerEnabled] = useState(
|
||||||
|
reverseProxy?.auth?.bearer_auth?.enabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [bearerGroups, setBearerGroups, { save: saveGroups }] = useGroupHelper({
|
||||||
|
initial: reverseProxy?.auth?.bearer_auth?.distribution_groups ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [linkAuthEnabled, setLinkAuthEnabled] = useState(
|
||||||
|
reverseProxy?.auth?.link_auth?.enabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth modal states
|
||||||
|
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||||
|
const [ssoModalOpen, setSsoModalOpen] = useState(false);
|
||||||
|
const [pinModalOpen, setPinModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Target being added/edited
|
||||||
|
const [targetModalOpen, setTargetModalOpen] = useState(false);
|
||||||
|
const [editingTargetIndex, setEditingTargetIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSubdomainValid = useMemo(() => {
|
||||||
|
return (
|
||||||
|
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists
|
||||||
|
);
|
||||||
|
}, [subdomain, baseDomain, domainAlreadyExists]);
|
||||||
|
|
||||||
|
const canContinueToSettings = useMemo(() => {
|
||||||
|
return isSubdomainValid && targets.length > 0;
|
||||||
|
}, [isSubdomainValid, targets]);
|
||||||
|
|
||||||
|
const submitDisabled = useMemo(() => {
|
||||||
|
return !canContinueToSettings;
|
||||||
|
}, [canContinueToSettings]);
|
||||||
|
|
||||||
|
const saveTarget = (targetData: ReverseProxyTarget) => {
|
||||||
|
if (editingTargetIndex !== null) {
|
||||||
|
// Update existing target
|
||||||
|
setTargets(
|
||||||
|
targets.map((t, i) =>
|
||||||
|
i === editingTargetIndex ? { ...t, ...targetData } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add new target
|
||||||
|
setTargets([...targets, targetData]);
|
||||||
|
}
|
||||||
|
setTargetModalOpen(false);
|
||||||
|
setEditingTargetIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editTarget = (index: number) => {
|
||||||
|
setEditingTargetIndex(index);
|
||||||
|
setTargetModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTarget = (index: number) => {
|
||||||
|
setTargets(targets.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTargetEnabled = (index: number) => {
|
||||||
|
setTargets(
|
||||||
|
targets.map((t, i) => (i === index ? { ...t, enabled: !t.enabled } : t)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNoAuth =
|
||||||
|
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Show warning if no authentication is configured
|
||||||
|
if (hasNoAuth) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: "No Authentication Configured",
|
||||||
|
description:
|
||||||
|
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
|
||||||
|
type: "warning",
|
||||||
|
confirmText: reverseProxy ? "Save Changes" : "Add Service",
|
||||||
|
cancelText: "Cancel",
|
||||||
|
maxWidthClass: "max-w-lg",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedGroups = await saveGroups();
|
||||||
|
|
||||||
|
const auth: ReverseProxyAuth = {
|
||||||
|
password_auth: {
|
||||||
|
enabled: passwordEnabled,
|
||||||
|
password: password,
|
||||||
|
},
|
||||||
|
pin_auth: {
|
||||||
|
enabled: pinEnabled,
|
||||||
|
pin: pin,
|
||||||
|
},
|
||||||
|
bearer_auth: {
|
||||||
|
enabled: bearerEnabled,
|
||||||
|
distribution_groups: savedGroups.map((g) => g.id as string),
|
||||||
|
},
|
||||||
|
link_auth: {
|
||||||
|
enabled: linkAuthEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCreateOrUpdateProxy({
|
||||||
|
data: {
|
||||||
|
name: fullDomain,
|
||||||
|
domain: fullDomain,
|
||||||
|
targets,
|
||||||
|
enabled: reverseProxy?.enabled ?? true,
|
||||||
|
pass_host_header: passHostHeader,
|
||||||
|
rewrite_redirects: rewriteRedirects,
|
||||||
|
auth,
|
||||||
|
},
|
||||||
|
proxyId: reverseProxy?.id,
|
||||||
|
onSuccess: () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||||
|
<ModalContent
|
||||||
|
maxWidthClass={tab === "service" ? "max-w-xl" : "max-w-2xl"}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
icon={<ReverseProxyIcon className={"fill-netbird"} size={18} />}
|
||||||
|
title={reverseProxy ? "Edit Service" : "Add Service"}
|
||||||
|
description={
|
||||||
|
"Expose services securely through NetBird's reverse proxy."
|
||||||
|
}
|
||||||
|
color={"netbird"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList justify={"start"} className={"px-8"}>
|
||||||
|
<TabsTrigger value={"targets"}>
|
||||||
|
<Text size={14} />
|
||||||
|
Details
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
|
||||||
|
<LockKeyhole size={16} />
|
||||||
|
Authentication
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
|
||||||
|
<Settings size={14} />
|
||||||
|
Settings
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={"targets"} className={"pb-8"}>
|
||||||
|
<div className={"px-8 flex-col flex gap-6"}>
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
<GlobeIcon size={14} />
|
||||||
|
Domain
|
||||||
|
</Label>
|
||||||
|
<HelpText>
|
||||||
|
Enter a subdomain and select a domain for your service.
|
||||||
|
</HelpText>
|
||||||
|
<div className="flex items-start mt-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={subdomain}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSubdomain(
|
||||||
|
e.target.value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, ""),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
error={
|
||||||
|
domainAlreadyExists
|
||||||
|
? "This domain is already used by another service."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
placeholder={"myapp"}
|
||||||
|
className="!rounded-r-none !border-r-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CustomDomainSelector
|
||||||
|
value={baseDomain}
|
||||||
|
onChange={setBaseDomain}
|
||||||
|
className="!rounded-l-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reverseProxy?.proxy_cluster && (
|
||||||
|
<Callout variant={"error"}>
|
||||||
|
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the
|
||||||
|
proxy server is running and connected to the right management
|
||||||
|
address.
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
<Server size={14} />
|
||||||
|
Targets
|
||||||
|
</Label>
|
||||||
|
<HelpText>
|
||||||
|
Add one or more devices running your service or resources to
|
||||||
|
make it publicly accessible.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
{targets.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{targets.map((target, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
onClick={() => editTarget(index)}
|
||||||
|
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
|
||||||
|
>
|
||||||
|
<td className="py-2.5 pl-5 pr-2 align-middle">
|
||||||
|
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
|
||||||
|
{target.path
|
||||||
|
? target.path.startsWith("/")
|
||||||
|
? target.path
|
||||||
|
: `/${target.path}`
|
||||||
|
: "/"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 align-middle">
|
||||||
|
<ArrowRight
|
||||||
|
size={12}
|
||||||
|
className="text-nb-gray-400"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-2 align-middle">
|
||||||
|
<TargetDestination target={target} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pl-2 pr-4">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
size="small"
|
||||||
|
checked={target.enabled !== false}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleTargetEnabled(index)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="default-outline"
|
||||||
|
className="!px-3"
|
||||||
|
>
|
||||||
|
<MoreVertical
|
||||||
|
size={16}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-auto min-w-[200px]"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => editTarget(index)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Edit size={14} className="shrink-0" />
|
||||||
|
Edit Target
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant={"danger"}
|
||||||
|
onClick={() => removeTarget(index)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<MinusCircleIcon
|
||||||
|
size={14}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
Remove Target
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="dotted"
|
||||||
|
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTargetModalOpen(true)}
|
||||||
|
disabled={
|
||||||
|
!!(initialNetwork && !initialNetwork.resources?.length)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlusIcon size={14} />
|
||||||
|
Add Target
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{initialNetwork && !initialNetwork.resources?.length && (
|
||||||
|
<Callout
|
||||||
|
variant="warning"
|
||||||
|
className="mt-3"
|
||||||
|
icon={
|
||||||
|
<AlertTriangle
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 relative top-[3px]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
There are currently no resources in your network{" "}
|
||||||
|
<span className={"text-netbird-100 font-medium"}>
|
||||||
|
{initialNetwork?.name}
|
||||||
|
</span>
|
||||||
|
. Add resources to your network before exposing it as a
|
||||||
|
service.{" "}
|
||||||
|
<InlineButtonLink
|
||||||
|
variant={"default"}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
router.push(
|
||||||
|
`/network?id=${initialNetwork.id}&tab=resources`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Resources
|
||||||
|
<ArrowUpRight size={14} />
|
||||||
|
</InlineButtonLink>
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value={"auth"} className={"pb-8"}>
|
||||||
|
<div className={"px-8 flex-col flex gap-4"}>
|
||||||
|
<SettingCard>
|
||||||
|
<SettingCard.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Users size={15} />
|
||||||
|
SSO (Single Sign-On)
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description="Require users to authenticate via SSO to access this service."
|
||||||
|
enabled={bearerEnabled}
|
||||||
|
onClick={() => setSsoModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<SettingCard.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<RectangleEllipsis size={15} />
|
||||||
|
Password
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description="Require a password to access this service."
|
||||||
|
enabled={passwordEnabled}
|
||||||
|
onClick={() => setPasswordModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<SettingCard.Item
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Binary size={15} />
|
||||||
|
PIN Code
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description="Require a numeric PIN code to access this service."
|
||||||
|
enabled={pinEnabled}
|
||||||
|
onClick={() => setPinModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</SettingCard>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value={"settings"} className={"pb-8"}>
|
||||||
|
<div className={"px-8 flex-col flex gap-4"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={passHostHeader}
|
||||||
|
onChange={setPassHostHeader}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<GlobeIcon size={15} />
|
||||||
|
Pass Host Header
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
helpText="Forward the original Host header to the backend instead of rewriting it to the target address."
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={rewriteRedirects}
|
||||||
|
onChange={setRewriteRedirects}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<ArrowRight size={15} />
|
||||||
|
Rewrite Redirects
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<ModalFooter className={"items-center"}>
|
||||||
|
<div className={"w-full"}>
|
||||||
|
{tab === "targets" && (
|
||||||
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_SERVICES_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Services
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "auth" && (
|
||||||
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_AUTHENTICATION_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Authentication
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "settings" && (
|
||||||
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_SETTINGS_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={"flex gap-3 w-full justify-end"}>
|
||||||
|
{!reverseProxy ? (
|
||||||
|
<>
|
||||||
|
{tab === "targets" && (
|
||||||
|
<>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant={"secondary"}>Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
onClick={() => setTab("auth")}
|
||||||
|
disabled={!canContinueToSettings}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "auth" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={"secondary"}
|
||||||
|
onClick={() => setTab("targets")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
onClick={() => setTab("settings")}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "settings" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={"secondary"}
|
||||||
|
onClick={() => setTab("auth")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
disabled={submitDisabled || !permission?.services?.create}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} />
|
||||||
|
Add Service
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant={"secondary"}>Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
disabled={submitDisabled || !permission?.services?.update}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
|
||||||
|
<ReverseProxyTargetModal
|
||||||
|
key={targetModalOpen ? 1 : 0}
|
||||||
|
open={targetModalOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setTargetModalOpen(open);
|
||||||
|
if (!open) setEditingTargetIndex(null);
|
||||||
|
}}
|
||||||
|
onSave={saveTarget}
|
||||||
|
currentTarget={
|
||||||
|
editingTargetIndex !== null ? targets[editingTargetIndex] : null
|
||||||
|
}
|
||||||
|
reverseProxy={{
|
||||||
|
id: reverseProxy?.id || "",
|
||||||
|
name: fullDomain,
|
||||||
|
domain: fullDomain,
|
||||||
|
targets: targets,
|
||||||
|
enabled: reverseProxy?.enabled ?? true,
|
||||||
|
}}
|
||||||
|
initialResource={initialResource}
|
||||||
|
initialPeer={initialPeer}
|
||||||
|
initialNetwork={initialNetwork}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthPasswordModal
|
||||||
|
open={passwordModalOpen}
|
||||||
|
key={passwordModalOpen ? "pass1" : "pass0"}
|
||||||
|
onOpenChange={setPasswordModalOpen}
|
||||||
|
currentPassword={password}
|
||||||
|
isEnabled={passwordEnabled}
|
||||||
|
onSave={(newPassword) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setPassword(newPassword);
|
||||||
|
setPasswordEnabled(true);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setPassword("");
|
||||||
|
setPasswordEnabled(false);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthSSOModal
|
||||||
|
open={ssoModalOpen}
|
||||||
|
onOpenChange={setSsoModalOpen}
|
||||||
|
key={ssoModalOpen ? "sso1" : "sso0"}
|
||||||
|
currentGroups={bearerGroups}
|
||||||
|
onSave={(groups) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setBearerGroups(groups);
|
||||||
|
setBearerEnabled(true);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setBearerGroups([]);
|
||||||
|
setBearerEnabled(false);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthPinModal
|
||||||
|
open={pinModalOpen}
|
||||||
|
onOpenChange={setPinModalOpen}
|
||||||
|
key={pinModalOpen ? "p1" : "p0"}
|
||||||
|
currentPin={pin}
|
||||||
|
isEnabled={pinEnabled}
|
||||||
|
onSave={(newPin) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setPin(newPin);
|
||||||
|
setPinEnabled(true);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setPin("");
|
||||||
|
setPinEnabled(false);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
|
||||||
|
const { resolveDestination } = useReverseProxies();
|
||||||
|
return (
|
||||||
|
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
|
||||||
|
{resolveDestination(target)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/modules/reverse-proxy/auth/AuthPasswordModal.tsx
Normal file
118
src/modules/reverse-proxy/auth/AuthPasswordModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import Button from "@components/Button";
|
||||||
|
import { Input } from "@components/Input";
|
||||||
|
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
|
||||||
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentPassword: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
onSave: (password: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthPasswordModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentPassword,
|
||||||
|
isEnabled,
|
||||||
|
onSave,
|
||||||
|
onRemove,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const [password, setPassword] = useState(currentPassword);
|
||||||
|
const [isMasked, setIsMasked] = useState(isEnabled && currentPassword === "");
|
||||||
|
const isEditing = isEnabled;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (password.trim()) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSave(password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setPassword("");
|
||||||
|
onRemove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent maxWidthClass="max-w-md">
|
||||||
|
<ModalHeader
|
||||||
|
title="Password"
|
||||||
|
description="Require a password to access this service."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GradientFadedBackground />
|
||||||
|
|
||||||
|
<div className="px-8">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={isMasked ? "**********" : password}
|
||||||
|
name={"service-password"}
|
||||||
|
showPasswordToggle={!isMasked}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (isMasked) {
|
||||||
|
setIsMasked(false);
|
||||||
|
setPassword(e.target.value.replace(/\*/g, ""));
|
||||||
|
} else {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter password..."
|
||||||
|
autoComplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-form-type="other"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && password.trim()) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 w-full justify-between mt-6">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button variant="danger-text" onClick={handleRemove}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!password.trim()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!password.trim()}
|
||||||
|
>
|
||||||
|
Add Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/modules/reverse-proxy/auth/AuthPinModal.tsx
Normal file
109
src/modules/reverse-proxy/auth/AuthPinModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Button from "@components/Button";
|
||||||
|
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
|
||||||
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
|
import PinCodeInput from "@components/PinCodeInput";
|
||||||
|
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentPin: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
onSave: (pin: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthPinModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentPin,
|
||||||
|
isEnabled,
|
||||||
|
onSave,
|
||||||
|
onRemove,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const [pin, setPin] = useState(currentPin);
|
||||||
|
const [isMasked, setIsMasked] = useState(isEnabled && currentPin === "");
|
||||||
|
const isEditing = isEnabled;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (pin.length === 6) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSave(pin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setPin("");
|
||||||
|
onRemove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent maxWidthClass="max-w-md">
|
||||||
|
<ModalHeader
|
||||||
|
title="PIN Code"
|
||||||
|
description="Require a numeric PIN code to access this service."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GradientFadedBackground />
|
||||||
|
|
||||||
|
<div className="px-8">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{isMasked ? (
|
||||||
|
<PinCodeInput
|
||||||
|
value={"******"}
|
||||||
|
onChange={(value) => {
|
||||||
|
const digit = value.replace(/\D/g, "");
|
||||||
|
setPin(digit);
|
||||||
|
setIsMasked(false);
|
||||||
|
}}
|
||||||
|
type={"password"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PinCodeInput value={pin} onChange={setPin} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full justify-between mt-6">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button variant="danger-text" onClick={handleRemove}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={pin.length !== 6}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={pin.length !== 6}
|
||||||
|
>
|
||||||
|
Add PIN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/modules/reverse-proxy/auth/AuthSSOModal.tsx
Normal file
97
src/modules/reverse-proxy/auth/AuthSSOModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import Button from "@components/Button";
|
||||||
|
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
|
||||||
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
|
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||||
|
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentGroups: Group[];
|
||||||
|
onSave: (groups: Group[]) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthSSOModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentGroups,
|
||||||
|
onSave,
|
||||||
|
onRemove,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const [groups, setGroups] = useState<Group[]>(currentGroups);
|
||||||
|
const isEditing = currentGroups.length > 0;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (groups.length > 0) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSave(groups);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setGroups([]);
|
||||||
|
onRemove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent maxWidthClass="max-w-xl">
|
||||||
|
<ModalHeader
|
||||||
|
title="SSO (Single Sign-On)"
|
||||||
|
description="Require users to authenticate via SSO to access this service."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GradientFadedBackground />
|
||||||
|
|
||||||
|
<div className="px-8">
|
||||||
|
<PeerGroupSelector
|
||||||
|
values={groups}
|
||||||
|
onChange={setGroups}
|
||||||
|
placeholder="Select distribution groups..."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 w-full justify-between mt-6">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button variant="danger-text" onClick={handleRemove}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={groups.length === 0}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={groups.length === 0}
|
||||||
|
>
|
||||||
|
Add Groups
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/modules/reverse-proxy/domain/CustomDomainClusterCell.tsx
Normal file
34
src/modules/reverse-proxy/domain/CustomDomainClusterCell.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Badge from "@components/Badge";
|
||||||
|
import { Globe, Server } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import { ReverseProxyDomain } from "@/interfaces/ReverseProxy";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
domain: ReverseProxyDomain;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomDomainClusterCell({ domain }: Readonly<Props>) {
|
||||||
|
const hasCluster = !!domain.target_cluster;
|
||||||
|
|
||||||
|
if (!hasCluster) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="gray" className="font-normal">
|
||||||
|
<Globe size={12} />
|
||||||
|
All
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={"gray"} className={"font-normal"}>
|
||||||
|
<Server size={11} className={"text-nb-gray-400"} />
|
||||||
|
{domain.target_cluster}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/modules/reverse-proxy/domain/CustomDomainModal.tsx
Normal file
178
src/modules/reverse-proxy/domain/CustomDomainModal.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import Button from "@components/Button";
|
||||||
|
import { Callout } from "@components/Callout";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import { Input } from "@components/Input";
|
||||||
|
import { Label } from "@components/Label";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalClose,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
} from "@components/modal/Modal";
|
||||||
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import { validator } from "@utils/helpers";
|
||||||
|
import { ExternalLinkIcon, GlobeIcon, ServerIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
import {
|
||||||
|
REVERSE_PROXY_CLUSTERS_DOCS_LINK,
|
||||||
|
REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK,
|
||||||
|
ReverseProxyDomainType,
|
||||||
|
} from "@/interfaces/ReverseProxy";
|
||||||
|
import HelpText from "@components/HelpText";
|
||||||
|
import Separator from "@components/Separator";
|
||||||
|
import {
|
||||||
|
SelectDropdown,
|
||||||
|
SelectOption,
|
||||||
|
} from "@components/select/SelectDropdown";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onDomainSubmit: (domain: string, targetCluster: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomDomainModal = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onDomainSubmit,
|
||||||
|
}: Props) => {
|
||||||
|
const { domains } = useReverseProxies();
|
||||||
|
const [domain, setDomain] = useState("");
|
||||||
|
const [selectedCluster, setSelectedCluster] = useState("");
|
||||||
|
|
||||||
|
// Get available proxy clusters (free domains)
|
||||||
|
const availableClusters = useMemo(() => {
|
||||||
|
return domains?.filter((d) => d.type === ReverseProxyDomainType.FREE) || [];
|
||||||
|
}, [domains]);
|
||||||
|
|
||||||
|
// Auto-select first cluster if only one available
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (availableClusters.length === 1 && !selectedCluster) {
|
||||||
|
setSelectedCluster(availableClusters[0].domain);
|
||||||
|
}
|
||||||
|
}, [availableClusters, selectedCluster]);
|
||||||
|
|
||||||
|
const error = useMemo(() => {
|
||||||
|
if (!domain) return "";
|
||||||
|
const isValid = validator.isValidDomain(domain, {
|
||||||
|
allowWildcard: false,
|
||||||
|
allowOnlyTld: false,
|
||||||
|
preventLeadingAndTrailingDots: true,
|
||||||
|
});
|
||||||
|
if (!isValid) {
|
||||||
|
return "Please enter a valid TLD domain, e.g., company.com";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [domain]);
|
||||||
|
|
||||||
|
const isValidDomain = !error && domain.length > 0;
|
||||||
|
const canSubmit = isValidDomain && selectedCluster;
|
||||||
|
|
||||||
|
const addDomain = () => {
|
||||||
|
if (canSubmit && domain && selectedCluster) {
|
||||||
|
onDomainSubmit(domain, selectedCluster);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableClusterOptions = availableClusters.map((cluster) => {
|
||||||
|
return {
|
||||||
|
label: cluster.domain,
|
||||||
|
value: cluster.domain,
|
||||||
|
icon: ServerIcon,
|
||||||
|
} as SelectOption;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent maxWidthClass={"relative max-w-lg"} showClose={true}>
|
||||||
|
<ModalHeader
|
||||||
|
icon={<GlobeIcon size={20} />}
|
||||||
|
title={"Add Custom Domain"}
|
||||||
|
description={"You will need to verify the domain with DNS records"}
|
||||||
|
color={"netbird"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
|
||||||
|
{availableClusters.length === 0 ? (
|
||||||
|
<Callout variant="warning">
|
||||||
|
No proxy clusters are currently connected. Please ensure at least
|
||||||
|
one proxy is running before adding a domain. <br /> Learn more
|
||||||
|
about{" "}
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Proxy Clusters
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Callout>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label>Domain</Label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value.toLowerCase())}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && canSubmit) {
|
||||||
|
addDomain();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g., company.com"
|
||||||
|
error={error || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Target Proxy Cluster</Label>
|
||||||
|
<HelpText>
|
||||||
|
Select the cluster your CNAME record should point to
|
||||||
|
</HelpText>
|
||||||
|
<SelectDropdown
|
||||||
|
showSearch={false}
|
||||||
|
value={selectedCluster}
|
||||||
|
onChange={setSelectedCluster}
|
||||||
|
options={availableClusterOptions}
|
||||||
|
placeholder={"Select a proxy cluster..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModalFooter className={"items-center"}>
|
||||||
|
<div className={"w-full"}>
|
||||||
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Custom Domains
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<div className={"flex gap-3 w-full justify-end"}>
|
||||||
|
<ModalClose asChild={true}>
|
||||||
|
<Button variant={"secondary"}>Cancel</Button>
|
||||||
|
</ModalClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
onClick={addDomain}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
Add Domain
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
109
src/modules/reverse-proxy/domain/CustomDomainSelector.tsx
Normal file
109
src/modules/reverse-proxy/domain/CustomDomainSelector.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
SelectDropdown,
|
||||||
|
SelectOption,
|
||||||
|
} from "@components/select/SelectDropdown";
|
||||||
|
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
import { ReverseProxyDomainType } from "@/interfaces/ReverseProxy";
|
||||||
|
import { isNetBirdHosted } from "@utils/netbird";
|
||||||
|
|
||||||
|
interface DomainSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomDomainSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
}: DomainSelectorProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { domains } = useReverseProxies();
|
||||||
|
|
||||||
|
const options: SelectOption[] = useMemo(() => {
|
||||||
|
const opts: SelectOption[] = [];
|
||||||
|
|
||||||
|
// Add free domains (connected proxy clusters, e.g., .eu.proxy.netbird.io)
|
||||||
|
domains
|
||||||
|
?.filter((d) => d.type === ReverseProxyDomainType.FREE)
|
||||||
|
.forEach((domain) => {
|
||||||
|
opts.push({
|
||||||
|
value: domain.domain,
|
||||||
|
label: `.${domain.domain}`,
|
||||||
|
renderItem: () => (
|
||||||
|
<div className="flex items-center gap-2 w-full text-sm justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>.{domain.domain}</span>
|
||||||
|
</div>
|
||||||
|
{isNetBirdHosted() ? (
|
||||||
|
<SmallBadge text="Free" variant="green" size="md" />
|
||||||
|
) : (
|
||||||
|
<SmallBadge text="Cluster" variant="green" size="md" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add validated custom domains
|
||||||
|
domains
|
||||||
|
?.filter((d) => d.validated && d.type === ReverseProxyDomainType.CUSTOM)
|
||||||
|
.forEach((domain) => {
|
||||||
|
opts.push({
|
||||||
|
value: domain.domain,
|
||||||
|
label: `.${domain.domain}`,
|
||||||
|
renderItem: () => (
|
||||||
|
<div className="flex items-center gap-2 w-full text-sm justify-between">
|
||||||
|
<span>.{domain.domain}</span>
|
||||||
|
<SmallBadge text="Custom" variant="sky" size="md" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add "Add Custom Domain" option
|
||||||
|
opts.push({
|
||||||
|
value: "add_custom",
|
||||||
|
label: "Add Custom Domain",
|
||||||
|
renderItem: () => (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-netbird text-sm w-full">
|
||||||
|
<div className={"flex items-center gap-2"}>
|
||||||
|
<span>Add Custom Domain</span>
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}, [domains]);
|
||||||
|
|
||||||
|
const handleChange = (selectedValue: string) => {
|
||||||
|
if (selectedValue === "add_custom") {
|
||||||
|
router.push("/reverse-proxy/custom-domains");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(selectedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectDropdown
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={options}
|
||||||
|
popoverWidth={335}
|
||||||
|
showSearch={true}
|
||||||
|
searchPlaceholder="Search domains..."
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Select domain..."
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import Button from "@components/Button";
|
||||||
|
import Card from "@components/Card";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalClose,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
} from "@components/modal/Modal";
|
||||||
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
|
import Steps from "@components/Steps";
|
||||||
|
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||||
|
import { Mark } from "@components/ui/Mark";
|
||||||
|
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Callout } from "@components/Callout";
|
||||||
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
import {
|
||||||
|
REVERSE_PROXY_CLUSTERS_DOCS_LINK,
|
||||||
|
REVERSE_PROXY_DOMAIN_VERIFICATION_LINK,
|
||||||
|
ReverseProxyDomain,
|
||||||
|
ReverseProxyDomainType,
|
||||||
|
} from "@/interfaces/ReverseProxy";
|
||||||
|
import Paragraph from "@components/Paragraph";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
domain: ReverseProxyDomain;
|
||||||
|
onStartVerification: (domain: ReverseProxyDomain) => void;
|
||||||
|
targetCluster?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomDomainVerificationModal = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
domain,
|
||||||
|
onStartVerification,
|
||||||
|
targetCluster,
|
||||||
|
}: Props) => {
|
||||||
|
const { domains } = useReverseProxies();
|
||||||
|
|
||||||
|
const handleStartVerification = () => {
|
||||||
|
onStartVerification(domain);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get free domains (proxy clusters) as CNAME targets
|
||||||
|
const freeDomains =
|
||||||
|
domains?.filter((d) => d.type === ReverseProxyDomainType.FREE) || [];
|
||||||
|
|
||||||
|
// Use provided target cluster, or fall back to first available
|
||||||
|
const cnameTarget = targetCluster || freeDomains[0]?.domain || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent maxWidthClass={"relative max-w-[600px]"} showClose={true}>
|
||||||
|
<GradientFadedBackground />
|
||||||
|
<ModalHeader
|
||||||
|
icon={<GlobeIcon size={20} />}
|
||||||
|
title={"Verify Domain"}
|
||||||
|
description={domain.domain}
|
||||||
|
color={"netbird"}
|
||||||
|
/>
|
||||||
|
<div className={"px-8 flex flex-col gap-0 pb-6"}>
|
||||||
|
<Steps className={"pt-0 stepper-bg-variant"}>
|
||||||
|
<Steps.Step step={1}>
|
||||||
|
<p className={"font-normal"}>
|
||||||
|
Sign in to your domain name provider (e.g. cloudflare.com or
|
||||||
|
godaddy.com)
|
||||||
|
</p>
|
||||||
|
</Steps.Step>
|
||||||
|
<Steps.Step step={2} line={false}>
|
||||||
|
<p className={"font-normal"}>
|
||||||
|
Add the <Mark>CNAME record</Mark> below to your DNS
|
||||||
|
configuration for <Mark>{domain.domain}</Mark>
|
||||||
|
</p>
|
||||||
|
</Steps.Step>
|
||||||
|
</Steps>
|
||||||
|
<div className={"flex flex-col gap-6"}>
|
||||||
|
{!cnameTarget ? (
|
||||||
|
<Callout variant={"warning"}>
|
||||||
|
No proxy clusters are currently connected. Please ensure at
|
||||||
|
least one proxy is running to configure DNS verification. <br />
|
||||||
|
Learn more about{" "}
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Proxy Clusters
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Callout>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className={"w-full"}>
|
||||||
|
<Card.List>
|
||||||
|
<Card.ListItem
|
||||||
|
copy
|
||||||
|
copyText={`*.${domain.domain}`}
|
||||||
|
label={"CNAME Record"}
|
||||||
|
value={`*.${domain.domain}`}
|
||||||
|
/>
|
||||||
|
<Card.ListItem
|
||||||
|
copy
|
||||||
|
copyText={cnameTarget}
|
||||||
|
label={"CNAME Content"}
|
||||||
|
value={cnameTarget}
|
||||||
|
/>
|
||||||
|
</Card.List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!targetCluster && freeDomains.length > 1 && (
|
||||||
|
<Callout variant={"info"}>
|
||||||
|
<span className="font-medium">
|
||||||
|
Available proxy clusters:
|
||||||
|
</span>{" "}
|
||||||
|
{freeDomains.map((d) => d.domain).join(", ")}. Choose the
|
||||||
|
cluster closest to your users for best performance.
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Callout variant={"warning"}>
|
||||||
|
DNS changes may take some time to propagate. If NetBird does
|
||||||
|
not find the record immediately, please wait up to 24 hours
|
||||||
|
and try again.
|
||||||
|
</Callout>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModalFooter className={"items-center"}>
|
||||||
|
<div className={"w-full"}>
|
||||||
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
|
Learn more about
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_DOMAIN_VERIFICATION_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Domain Verification
|
||||||
|
<ExternalLinkIcon size={12} />
|
||||||
|
</InlineLink>
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<div className={"flex gap-3 w-full justify-end"}>
|
||||||
|
<ModalClose asChild={true}>
|
||||||
|
<Button variant={"secondary"}>Verify Later</Button>
|
||||||
|
</ModalClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
onClick={handleStartVerification}
|
||||||
|
disabled={!cnameTarget}
|
||||||
|
>
|
||||||
|
Start Verification
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user