Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b6895380 | ||
|
|
9e2e38764e | ||
|
|
d9fb379abf | ||
|
|
831673d0d6 | ||
|
|
bc4aac10aa | ||
|
|
38e14a6c64 | ||
|
|
b79c6615b4 | ||
|
|
5d4e491611 | ||
|
|
9b1f920863 |
30
.github/workflows/build_and_push.yml
vendored
30
.github/workflows/build_and_push.yml
vendored
@@ -28,6 +28,33 @@ jobs:
|
||||
|
||||
- run: echo '{}' > .local-config.json
|
||||
|
||||
- name: Download IronRDP release TS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release JS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release WASM file
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
-
|
||||
@@ -44,7 +71,6 @@ jobs:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.NB_DOCKER_USER }}
|
||||
@@ -55,7 +81,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,3 +41,8 @@ next-env.d.ts
|
||||
.configs/.staging-config.json
|
||||
.configs/.temp-config.json
|
||||
.configs
|
||||
|
||||
/public/ironrdp-pkg/
|
||||
/public/netbird.wasm
|
||||
.idea
|
||||
src/.local-config*
|
||||
@@ -26,6 +26,7 @@ The dashboard makes it possible to:
|
||||
- NextJS
|
||||
- ReactJS
|
||||
- Tailwind CSS
|
||||
- [React Flow](https://reactflow.dev/) for the Control Center
|
||||
- Auth0
|
||||
- Nginx
|
||||
- Docker
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
|
||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
||||
}
|
||||
@@ -3,6 +3,14 @@ server {
|
||||
listen [::]:80 default_server;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
location = /netbird.wasm {
|
||||
root /usr/share/nginx/html;
|
||||
default_type application/wasm;
|
||||
}
|
||||
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
|
||||
root /usr/share/nginx/html;
|
||||
default_type application/wasm;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ =404;
|
||||
|
||||
@@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH:-https://pkgs.netbird.io/wasm/client}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
@@ -101,6 +101,7 @@ http {
|
||||
application/rss+xml
|
||||
application/vnd.geo+json
|
||||
application/vnd.ms-fontobject
|
||||
application/wasm
|
||||
application/x-font-ttf
|
||||
application/x-web-app-manifest+json
|
||||
application/xhtml+xml
|
||||
|
||||
983
package-lock.json
generated
983
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^7.22.18",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -35,11 +36,15 @@
|
||||
"@tanstack/match-sorter-utils": "^8.8.4",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "^4.14.200",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"autoprefixer": "^10",
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
@@ -47,8 +52,10 @@
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
@@ -58,7 +65,7 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.481.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^14.2.28",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
|
||||
575
public/wasm_exec.js
Normal file
575
public/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
8
src/app/(dashboard)/control-center/layout.tsx
Normal file
8
src/app/(dashboard)/control-center/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Control Center - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
1290
src/app/(dashboard)/control-center/page.tsx
Normal file
1290
src/app/(dashboard)/control-center/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Separator from "@components/Separator";
|
||||
@@ -12,12 +20,14 @@ import { cn } from "@utils/helpers";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
HelpCircle,
|
||||
MoreVertical,
|
||||
PencilLineIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
@@ -25,8 +35,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import NetworkModal from "@/modules/networks/NetworkModal";
|
||||
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
} from "@/modules/networks/NetworkProvider";
|
||||
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
|
||||
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
|
||||
|
||||
@@ -77,35 +89,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
{permission.networks.update && (
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setNetworkModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
onUpdated={() => {
|
||||
mutate(`/networks/${network.id}`);
|
||||
}}
|
||||
network={network}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkProvider network={network}>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +125,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkActions() {
|
||||
const { permission } = usePermissions();
|
||||
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
|
||||
const router = useRouter();
|
||||
|
||||
if (!network) return;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button variant={"secondary"} className={"!px-3"}>
|
||||
<MoreVertical size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto" align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => openEditNetworkModal(network)}
|
||||
disabled={!permission.networks.update}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<PencilLineIcon size={14} className={"shrink-0"} />
|
||||
Rename
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
deleteNetwork(network).then(() => router.push("/networks"))
|
||||
}
|
||||
variant={"danger"}
|
||||
disabled={!permission.networks.delete}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<Trash2 size={14} className={"shrink-0"} />
|
||||
Delete
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
const isHighlyAvailable = !!(
|
||||
network?.routing_peers_count && network?.routing_peers_count >= 2
|
||||
@@ -154,7 +205,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
const policyCount = network.policies?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={"w-full lg:w-1/2"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
|
||||
@@ -4,8 +4,6 @@ import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
@@ -33,16 +31,15 @@ import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
History,
|
||||
LockIcon,
|
||||
MapPin,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
TerminalSquare,
|
||||
TimerResetIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -65,6 +62,9 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -80,9 +80,8 @@ export default function PeerPage() {
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
let ssh = peer?.ssh_enabled ? "1" : "0";
|
||||
let expiration = peer?.login_expiration_enabled ? "1" : "0";
|
||||
return `${id}-${ssh}-${expiration}`;
|
||||
return `${id}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
if (isRestricted) {
|
||||
@@ -104,7 +103,7 @@ export default function PeerPage() {
|
||||
);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId}>
|
||||
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
|
||||
<PeerOverview key={peerKey} />
|
||||
</PeerProvider>
|
||||
) : (
|
||||
@@ -138,8 +137,7 @@ function PeerOverview() {
|
||||
const PeerGeneralInformation = () => {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
|
||||
const [ssh, setSsh] = useState(peer.ssh_enabled);
|
||||
const { peer, user, peerGroups, update } = usePeer();
|
||||
const [name, setName] = useState(peer.name);
|
||||
const [showEditNameModal, setShowEditNameModal] = useState(false);
|
||||
const [loginExpiration, setLoginExpiration] = useState(
|
||||
@@ -158,7 +156,6 @@ const PeerGeneralInformation = () => {
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
@@ -171,7 +168,6 @@ const PeerGeneralInformation = () => {
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
@@ -187,7 +183,6 @@ const PeerGeneralInformation = () => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
@@ -311,41 +306,17 @@ const PeerGeneralInformation = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={permission.peers.update}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={ssh}
|
||||
disabled={!permission.peers.update}
|
||||
onChange={(set) =>
|
||||
!set
|
||||
? setSsh(false)
|
||||
: openSSHDialog().then((confirm) => setSsh(confirm))
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<TerminalSquare size={16} />
|
||||
SSH Access
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable the SSH server on this peer to access the machine via an secure shell."
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
<PeerSSHToggle />
|
||||
|
||||
{/* Remote Access Buttons */}
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permission.groups.read && (
|
||||
<div>
|
||||
@@ -569,6 +540,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{peer.created_at && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<CalendarDays size={16} />
|
||||
Registered on
|
||||
</>
|
||||
}
|
||||
value={
|
||||
dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") +
|
||||
" (" +
|
||||
dayjs().to(peer.created_at) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
|
||||
@@ -289,6 +289,7 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
|
||||
const neverLoggedIn = dayjs(user.last_login).isBefore(
|
||||
dayjs().subtract(1000, "years"),
|
||||
);
|
||||
const isPendingApproval = user?.pending_approval;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -328,18 +329,20 @@ function UserInformationCard({ user }: Readonly<{ user: User }>) {
|
||||
|
||||
{!isServiceUser && (
|
||||
<>
|
||||
{!user.is_current && user.role != Role.Owner && (
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
label={
|
||||
<>
|
||||
<Ban size={16} />
|
||||
Block User
|
||||
</>
|
||||
}
|
||||
value={<UserBlockCell user={user} isUserPage={true} />}
|
||||
/>
|
||||
)}
|
||||
{!user.is_current &&
|
||||
user.role != Role.Owner &&
|
||||
!isPendingApproval && (
|
||||
<Card.ListItem
|
||||
tooltip={false}
|
||||
label={
|
||||
<>
|
||||
<Ban size={16} />
|
||||
Block User
|
||||
</>
|
||||
}
|
||||
value={<UserBlockCell user={user} isUserPage={true} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
|
||||
9
src/app/(remote-access)/layout.tsx
Normal file
9
src/app/(remote-access)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<UsersProvider>{children}</UsersProvider>
|
||||
);
|
||||
}
|
||||
213
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
213
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
|
||||
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
|
||||
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
|
||||
import {
|
||||
RDPCredentials,
|
||||
RDPStatus,
|
||||
useRemoteDesktop,
|
||||
} from "@/modules/remote-access/rdp/useRemoteDesktop";
|
||||
import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
{peerId && peer && !isLoading ? (
|
||||
<RDPSession key={peer.id} peer={peer} />
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
function RDPSession({ peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
|
||||
const rdp = useRemoteDesktop(client);
|
||||
const [credentialsModal, setCredentialsModal] = useState(true);
|
||||
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
|
||||
const connected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${peer.name} - ${peer.ip} - RDP`;
|
||||
}, [peer.ip, peer.name, connected, rdp]);
|
||||
|
||||
const sendErrorNotification = (title: string, message: string) => {
|
||||
notify({
|
||||
title: title,
|
||||
description: message,
|
||||
icon: <IconCircleX size={24} />,
|
||||
backgroundColor: "bg-red-500",
|
||||
duration: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
setCredentials(null);
|
||||
connected.current = false;
|
||||
setCredentialsModal(true);
|
||||
rdp.session?.disconnect();
|
||||
await client.disconnect();
|
||||
}, [client, rdp]);
|
||||
|
||||
/**
|
||||
* Establishes a connection to the peer
|
||||
*/
|
||||
const connect = async (rdpCredentials: RDPCredentials) => {
|
||||
if (!peer?.id) return;
|
||||
if (client.status === NetBirdStatus.DISCONNECTED) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
"NetBird Connection Error",
|
||||
(error as Error).message,
|
||||
);
|
||||
setIsNetBirdConnecting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
if (!credentials) return;
|
||||
try {
|
||||
const result = await rdp.connect({
|
||||
hostname: peer.ip,
|
||||
port: credentials.port,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
domain: credentials.domain,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
if (result === RDPStatus.CONNECTED) {
|
||||
connected.current = true;
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
sendErrorNotification("RDP Connection Error", (error as Error).message);
|
||||
setCredentialsModal(true);
|
||||
await reset();
|
||||
}
|
||||
}, [credentials, peer.ip, rdp, reset]);
|
||||
|
||||
/**
|
||||
* Establish RDP session when NetBird connection is ready
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
client.status === NetBirdStatus.CONNECTED &&
|
||||
rdp.status === RDPStatus.DISCONNECTED &&
|
||||
credentials &&
|
||||
!connected.current &&
|
||||
!isNetBirdConnecting
|
||||
) {
|
||||
startSession().catch(console.error);
|
||||
}
|
||||
}, [
|
||||
client.status,
|
||||
credentials,
|
||||
peer.ip,
|
||||
rdp,
|
||||
startSession,
|
||||
isNetBirdConnecting,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display notifications for RDP and NetBird client errors
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rdp.error) {
|
||||
sendErrorNotification("RDP Error", rdp.error);
|
||||
}
|
||||
if (client.error) {
|
||||
sendErrorNotification("NetBird Client Error", client.error);
|
||||
}
|
||||
}, [rdp, client]);
|
||||
|
||||
/**
|
||||
* Close credentials modal when RDP is connected
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rdp.status === RDPStatus.CONNECTED) {
|
||||
setCredentialsModal(false);
|
||||
}
|
||||
}, [rdp.status]);
|
||||
|
||||
const isLoading =
|
||||
client.status === NetBirdStatus.CONNECTING ||
|
||||
rdp.status === RDPStatus.CONNECTING ||
|
||||
rdp.isResizing ||
|
||||
isNetBirdConnecting;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Credentials Modal */}
|
||||
<RDPCredentialsModal
|
||||
open={credentialsModal}
|
||||
peer={peer}
|
||||
onConnect={connect}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Certificate Modal */}
|
||||
<RDPCertificateModal
|
||||
open={!!rdp.pendingCertificate}
|
||||
certificateInfo={rdp.pendingCertificate}
|
||||
onAccept={rdp.acceptCertificatePrompt}
|
||||
onReject={async () => {
|
||||
rdp.rejectCertificatePrompt();
|
||||
await reset();
|
||||
}}
|
||||
/>
|
||||
|
||||
{rdp.isResizing && (
|
||||
<div
|
||||
className={
|
||||
"fixed w-screen h-screen z-50 backdrop-blur bg-black/50 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
<Loader2Icon size={20} className={"animate-spin"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RDP Canvas */}
|
||||
<canvas
|
||||
ref={rdp.canvasRef}
|
||||
className={cn(
|
||||
rdp.status === RDPStatus.CONNECTED ? "block" : "hidden",
|
||||
"w-full h-full select-none bg-nb-gray-950",
|
||||
)}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
236
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
|
||||
import { SSHStatus, useSSH } from "@/modules/remote-access/ssh/useSSH";
|
||||
import { useSSHQueryParams } from "@/modules/remote-access/ssh/useSSHQueryParams";
|
||||
import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
<ErrorMessage
|
||||
error={{
|
||||
message:
|
||||
"This peer may have been deleted, or you may not have permission to view it.",
|
||||
code: error.code,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
{peerId && peer && !isLoading && username && port ? (
|
||||
<SSHTerminal
|
||||
key={peer.id}
|
||||
peer={peer}
|
||||
username={username}
|
||||
port={port}
|
||||
/>
|
||||
) : (
|
||||
<LoadingMessage message={"Starting ssh session..."} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
username: string;
|
||||
port: string;
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
function SSHTerminal({ username, port, peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const connected = useRef(false);
|
||||
const sshConnectedOnce = useRef(false);
|
||||
|
||||
const {
|
||||
connect: ssh,
|
||||
disconnect,
|
||||
status,
|
||||
session,
|
||||
error: sshError,
|
||||
} = useSSH(client);
|
||||
|
||||
const isSSHConnecting = status === SSHStatus.CONNECTING;
|
||||
const isSSHConnected = status === SSHStatus.CONNECTED;
|
||||
const isSSHDisconnected = status === SSHStatus.DISCONNECTED;
|
||||
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
|
||||
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
|
||||
}, [username, peer, client]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
if (!peer?.id) return;
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
connected.current = false;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
port: Number(port),
|
||||
username,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reconnection failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
if (isClientConnecting || client.status === NetBirdStatus.CONNECTED) return;
|
||||
|
||||
const connect = async () => {
|
||||
if (!peer.id) return;
|
||||
if (connected.current) return;
|
||||
connected.current = true;
|
||||
try {
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const rules = [`tcp/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
port: Number(port),
|
||||
username,
|
||||
});
|
||||
if (res === SSHStatus.CONNECTED) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isClientDisconnected) connect().catch(console.error);
|
||||
}, [
|
||||
isClientDisconnected,
|
||||
isSSHConnected,
|
||||
isSSHConnecting,
|
||||
isClientConnecting,
|
||||
peer.id,
|
||||
port,
|
||||
ssh,
|
||||
username,
|
||||
client.connectTemporary,
|
||||
client.status,
|
||||
]);
|
||||
|
||||
if (client.error) {
|
||||
return <ErrorMessage error={{ message: client.error, code: 0 }} />;
|
||||
}
|
||||
|
||||
if (sshError) {
|
||||
return <ErrorMessage error={{ message: sshError, code: 0 }} />;
|
||||
}
|
||||
|
||||
if (isSSHDisconnected && sshConnectedOnce.current) {
|
||||
return (
|
||||
<DisconnectedMessage
|
||||
username={username}
|
||||
peerIp={peer.ip}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{session && <Terminal session={session} onClose={disconnect} />}
|
||||
{!isSSHConnected && (
|
||||
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageProps = {
|
||||
message?: string;
|
||||
error?: ErrorResponse;
|
||||
};
|
||||
|
||||
const LoadingMessage = ({ message }: MessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<Loader2Icon size={16} className={"animate-spin shrink-0"} />
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ error }: MessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<CircleXIcon size={16} className={"shrink-0 text-red-500"} />
|
||||
{error?.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DisconnectedMessageProps = {
|
||||
username: string;
|
||||
peerIp: string;
|
||||
onReconnect: () => void;
|
||||
};
|
||||
|
||||
const DisconnectedMessage = ({
|
||||
username,
|
||||
peerIp,
|
||||
onReconnect,
|
||||
}: DisconnectedMessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center gap-4"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<InfoIcon size={16} className={"shrink-0 text-nb-gray-200"} />
|
||||
Disconnected from {username}@{peerIp}
|
||||
<button
|
||||
className={
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1 text-netbird hover:underline font-normal"
|
||||
}
|
||||
onClick={onReconnect}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
src/app/error/page.tsx
Normal file
115
src/app/error/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useOidc } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import loadConfig from "@utils/config";
|
||||
import { ArrowRightIcon, RefreshCw } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
export default function ErrorPage() {
|
||||
const { logout, isAuthenticated } = useOidc();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [error, setError] = useState<{
|
||||
code: number;
|
||||
message: string;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Get error details from URL params
|
||||
const code = searchParams.get("code");
|
||||
const message = searchParams.get("message");
|
||||
const type = searchParams.get("type");
|
||||
|
||||
if (code && message) {
|
||||
setError({
|
||||
code: parseInt(code),
|
||||
message: decodeURIComponent(message),
|
||||
type: type || "error",
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLogout = () => {
|
||||
// Use the same logout pattern as OIDCError
|
||||
logout("/", { client_id: config.clientId });
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// If not authenticated, redirect to home
|
||||
router.push("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBlockedUser =
|
||||
error?.code === 403 && error?.message?.toLowerCase().includes("blocked");
|
||||
const isPendingApproval =
|
||||
error?.code === 403 &&
|
||||
error?.message?.toLowerCase().includes("pending approval");
|
||||
|
||||
const getTitle = () => {
|
||||
if (isBlockedUser) return "User Account Blocked";
|
||||
if (isPendingApproval) return "User Approval Pending";
|
||||
return "Access Error";
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (isBlockedUser) {
|
||||
return "Your access has been blocked by the NetBird account administrator, possibly due to new user approval requirements or security policies. Please contact your administrator to regain access.";
|
||||
}
|
||||
if (isPendingApproval) {
|
||||
return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard.";
|
||||
}
|
||||
return "An error occurred while trying to access the dashboard. Please try again or contact your administrator.";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-col h-screen max-w-xl mx-auto">
|
||||
<div className="bg-nb-gray-930 mb-3 border border-nb-gray-900 h-12 w-12 rounded-md flex items-center justify-center">
|
||||
<NetBirdIcon size={23} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-center mt-2">{getTitle()}</h1>
|
||||
|
||||
<Paragraph className="text-center mt-2 block">
|
||||
{getDescription()}
|
||||
</Paragraph>
|
||||
|
||||
{error && (
|
||||
<div className="bg-nb-gray-930 border border-nb-gray-800 rounded-md p-4 mt-4 max-w-md font-mono mb-2">
|
||||
<div className="text-center text-sm text-netbird">
|
||||
<div>response_message: {error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Paragraph className="text-center mt-2 text-sm">
|
||||
If you believe this is an error, please contact your administrator.
|
||||
</Paragraph>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{!isBlockedUser && !isPendingApproval && (
|
||||
<Button variant="default-outline" size="sm" onClick={handleRetry}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={handleLogout}>
|
||||
{isBlockedUser || isPendingApproval ? "Sign Out" : "Logout"}
|
||||
<ArrowRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,3 +157,20 @@ p {
|
||||
.animate-bg-scroll-faster {
|
||||
animation: bg-scroll 1.8s linear infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal (xterm)
|
||||
*/
|
||||
.xterm {
|
||||
@apply m-0 p-1 box-border h-full w-full;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
@apply m-0 p-0 box-border;
|
||||
}
|
||||
|
||||
|
||||
/* Control Center */
|
||||
.react-flow__node-groupNode .selected{
|
||||
@apply border-netbird;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function NotFound() {
|
||||
}
|
||||
|
||||
const Redirect = ({ url, queryParams }: Props) => {
|
||||
useRedirect("/peers" + (queryParams && `?${queryParams}`));
|
||||
const params = queryParams && `?${queryParams}`;
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
22
src/assets/icons/ControlCenterIcon.tsx
Normal file
22
src/assets/icons/ControlCenterIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function ControlCenterIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path d="M5 3a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm0 12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2H5Zm12 0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-2Zm0-12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-2Z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 6.5a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1ZM10 18a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm-4-4a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Zm12 0a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/ssh/ssh-client.png
Normal file
BIN
src/assets/ssh/ssh-client.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 290 KiB |
@@ -58,6 +58,10 @@ export default function OIDCProvider({ children }: Props) {
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
"page",
|
||||
"page_size",
|
||||
"user",
|
||||
"port",
|
||||
];
|
||||
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const buttonVariants = cva(
|
||||
"relative",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm",
|
||||
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||
"disabled:opacity-20 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -34,7 +34,7 @@ export const buttonVariants = cva(
|
||||
secondary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const calloutVariants = cva(
|
||||
|
||||
export const Callout = ({
|
||||
children,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
|
||||
@@ -25,6 +25,8 @@ type Props = {
|
||||
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
alignOffset?: number;
|
||||
sideOffset?: number;
|
||||
} & TooltipProps &
|
||||
TooltipVariants;
|
||||
|
||||
@@ -45,6 +47,8 @@ export default function FullTooltip({
|
||||
delayDuration = 1,
|
||||
skipDelayDuration = 300,
|
||||
variant = "default",
|
||||
alignOffset = 20,
|
||||
sideOffset,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(!!keepOpen);
|
||||
|
||||
@@ -83,7 +87,8 @@ export default function FullTooltip({
|
||||
)}
|
||||
{!disabled && (
|
||||
<TooltipContent
|
||||
alignOffset={20}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
forceMount={true}
|
||||
className={contentClassName}
|
||||
variant={variant}
|
||||
|
||||
29
src/components/ListItem.tsx
Normal file
29
src/components/ListItem.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export const ListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/components/NoPeersGettingStarted.tsx
Normal file
44
src/components/NoPeersGettingStarted.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
|
||||
type Props = {
|
||||
showBackground?: boolean;
|
||||
};
|
||||
|
||||
export const NoPeersGettingStarted = ({ showBackground = true }) => {
|
||||
return (
|
||||
<GetStartedTest
|
||||
showBackground={showBackground}
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Get Started with NetBird"}
|
||||
description={
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Getting Started Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -42,8 +42,15 @@ import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item?.id?.toLowerCase().includes(lowerCaseQuery) ?? false;
|
||||
};
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: Group[];
|
||||
onChange: React.Dispatch<React.SetStateAction<Group[]>>;
|
||||
@@ -60,6 +67,7 @@ interface MultiSelectProps {
|
||||
dataCy?: string;
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
@@ -67,6 +75,7 @@ interface MultiSelectProps {
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -84,6 +93,7 @@ export function PeerGroupSelector({
|
||||
dataCy = "group-selector-dropdown",
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
resource,
|
||||
onResourceChange,
|
||||
placeholder = "Add or select group(s)...",
|
||||
@@ -91,16 +101,36 @@ export function PeerGroupSelector({
|
||||
align = "start",
|
||||
side = "bottom",
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
|
||||
useGroups();
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputRef, { width }] = useElementSize<
|
||||
HTMLButtonElement | HTMLSpanElement
|
||||
>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
const [filteredGroups, search, setSearch] = useSearch(
|
||||
sortedDropdownOptions,
|
||||
groupsSearchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
// Update dropdown options when groups change
|
||||
@@ -189,16 +219,6 @@ export function PeerGroupSelector({
|
||||
return isSearching && groupDoesNotExist && !isAllGroup;
|
||||
}, [search, dropdownOptions]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const folderIcon = useMemo(() => {
|
||||
return <FolderGit2 size={12} className={"shrink-0"} />;
|
||||
}, []);
|
||||
|
||||
const peerIcon = useMemo(() => {
|
||||
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
|
||||
}, []);
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
const [tab, setTab] = useState("groups");
|
||||
@@ -219,12 +239,6 @@ export function PeerGroupSelector({
|
||||
onChange(union);
|
||||
};
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
// Reset the search input when switching tabs
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
@@ -233,10 +247,12 @@ export function PeerGroupSelector({
|
||||
}, 0);
|
||||
}, [tab]);
|
||||
|
||||
const searchPlaceholder =
|
||||
tab === "groups"
|
||||
? 'Search groups or add new group by pressing "Enter"...'
|
||||
: "Search resource...";
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
@@ -250,6 +266,15 @@ export function PeerGroupSelector({
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
const selectPeer = (peer?: Peer) => {
|
||||
if (!peer?.id) return;
|
||||
onResourceChange?.({
|
||||
id: peer.id,
|
||||
type: "peer",
|
||||
});
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -284,10 +309,11 @@ export function PeerGroupSelector({
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{resource && showResources && (
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -364,16 +390,7 @@ export function PeerGroupSelector({
|
||||
side={side}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<Command className={"w-full flex"} loop shouldFilter={false}>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
@@ -414,13 +431,17 @@ export function PeerGroupSelector({
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
|
||||
{showResources && <TabTriggers searchRef={searchRef} />}
|
||||
<TabTriggers
|
||||
searchRef={searchRef}
|
||||
showPeers={showPeers}
|
||||
showResources={showResources}
|
||||
/>
|
||||
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
||||
<CommandGroup>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
|
||||
sortedDropdownOptions.length == 0 && !search && "py-0",
|
||||
filteredGroups.length == 0 && !search && "py-0",
|
||||
)}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
@@ -433,8 +454,8 @@ export function PeerGroupSelector({
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
<Badge variant={"gray-ghost"} className={"h-7"}>
|
||||
<FolderGit2 size={12} className={"shrink-0"} />
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
@@ -448,7 +469,7 @@ export function PeerGroupSelector({
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
{filteredGroups.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
@@ -490,7 +511,11 @@ export function PeerGroupSelector({
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
<GroupBadge
|
||||
group={option}
|
||||
showNewBadge={true}
|
||||
className={"h-7"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
@@ -509,7 +534,10 @@ export function PeerGroupSelector({
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
<MonitorSmartphoneIcon
|
||||
size={14}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
) : (
|
||||
@@ -535,12 +563,23 @@ export function PeerGroupSelector({
|
||||
<ResourcesList
|
||||
search={search}
|
||||
resources={resources}
|
||||
isLoading={isLoading}
|
||||
isLoading={isResourcesLoading}
|
||||
value={resource}
|
||||
onChange={selectResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{showPeers && (
|
||||
<TabsContent value={"peers"} className={"p-0 my-0"}>
|
||||
<PeersList
|
||||
search={search}
|
||||
peers={peers}
|
||||
isLoading={isPeersLoading}
|
||||
value={resource}
|
||||
onChange={selectPeer}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -551,9 +590,15 @@ export function PeerGroupSelector({
|
||||
|
||||
const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
}) => {
|
||||
if (!showResources && !showPeers) return null;
|
||||
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
<TabsTrigger
|
||||
@@ -569,19 +614,38 @@ const TabTriggers = ({
|
||||
/>
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<Layers3Icon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Resource
|
||||
</TabsTrigger>
|
||||
|
||||
{showResources && (
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<Layers3Icon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Resources
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{showPeers && (
|
||||
<TabsTrigger
|
||||
value={"peers"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<MonitorSmartphoneIcon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -700,7 +764,7 @@ const ResourcesList = ({
|
||||
useHover={true}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap")}
|
||||
className={cn("transition-all group whitespace-nowrap h-7")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
@@ -736,3 +800,107 @@ const ResourcesList = ({
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
const peersSearchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ip.toLowerCase().includes(lowerCaseQuery);
|
||||
};
|
||||
|
||||
const PeersList = ({
|
||||
search,
|
||||
peers,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
search: string;
|
||||
peers?: Peer[];
|
||||
isLoading: boolean;
|
||||
value?: PolicyRuleResource;
|
||||
onChange: (peer: Peer) => void;
|
||||
}) => {
|
||||
const [filteredItems, _, setSearch] = useSearch(
|
||||
peers || [],
|
||||
peersSearchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(search);
|
||||
}, [search, setSearch]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (search != "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no peers matching your search. Please try a different search
|
||||
term.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
if (search == "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no peers available yet. <br />
|
||||
Go to <InlineLink href={"/peers"}>Peers</InlineLink> to add some peers.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value?.id} name={"peer"} value={value?.id}>
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={onChange}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
if (!res?.id) return;
|
||||
|
||||
return (
|
||||
<Fragment key={res.id}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
useHover={true}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap h-7 px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<PeerOperatingSystemIcon os={res.os} />
|
||||
<TextWithTooltip text={res?.name || ""} maxChars={20} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{res.ip}
|
||||
<RadioItem value={res.id} />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import FullTooltip from "@components/FullTooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -16,7 +15,7 @@ import { memo, useEffect, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
const MapPinIcon = memo(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
@@ -182,7 +181,6 @@ export function PeerSelector({
|
||||
togglePeer(item);
|
||||
}}
|
||||
renderItem={(option) => {
|
||||
const os = getOperatingSystem(option.os);
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
option.version,
|
||||
option.os,
|
||||
@@ -210,19 +208,10 @@ export function PeerSelector({
|
||||
: "text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
os === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
os === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
os === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={option.os} />
|
||||
</div>
|
||||
|
||||
<PeerOperatingSystemIcon
|
||||
os={option.os}
|
||||
className={isSupported ? "" : "opacity-50"}
|
||||
/>
|
||||
<div className={cn(!isSupported && "opacity-50")}>
|
||||
<TextWithTooltip
|
||||
text={option.name}
|
||||
|
||||
@@ -44,10 +44,12 @@ function Trigger({
|
||||
children,
|
||||
value,
|
||||
disabled = false,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const currentValue = useTabContext();
|
||||
return (
|
||||
@@ -60,6 +62,7 @@ function Trigger({
|
||||
: disabled
|
||||
? ""
|
||||
: "text-nb-gray-400 hover:bg-nb-gray-900/50",
|
||||
className,
|
||||
)}
|
||||
value={value}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props<T extends { id?: string }> = {
|
||||
renderHeading?: (item: T) => React.ReactNode;
|
||||
renderBeforeItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
itemClassNameWithItem?: (item: T) => string;
|
||||
itemWrapperClassName?: string;
|
||||
scrollAreaClassName?: string;
|
||||
maxHeight?: number;
|
||||
@@ -21,6 +22,7 @@ type Props<T extends { id?: string }> = {
|
||||
estimatedHeadingHeight?: number;
|
||||
heightAdjustment?: number;
|
||||
groupKey?: (item: T) => string | undefined;
|
||||
itemKey?: (item: T) => string;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
@@ -30,6 +32,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
renderBeforeItem,
|
||||
renderHeading,
|
||||
itemClassName,
|
||||
itemClassNameWithItem,
|
||||
itemWrapperClassName,
|
||||
scrollAreaClassName,
|
||||
maxHeight,
|
||||
@@ -37,6 +40,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
estimatedHeadingHeight = 16,
|
||||
heightAdjustment = 8,
|
||||
groupKey,
|
||||
itemKey,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
|
||||
@@ -159,10 +163,14 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
setSelected(index);
|
||||
}
|
||||
}}
|
||||
id={option.id}
|
||||
id={itemKey ? itemKey(option) : option?.id}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
itemClassName={itemClassName}
|
||||
itemClassName={
|
||||
itemClassNameWithItem
|
||||
? itemClassNameWithItem(option)
|
||||
: itemClassName
|
||||
}
|
||||
className={itemWrapperClassName}
|
||||
isLast={index === items.length - 1}
|
||||
>
|
||||
|
||||
@@ -37,6 +37,10 @@ interface SelectDropdownProps {
|
||||
searchPlaceholder?: string;
|
||||
isLoading?: boolean;
|
||||
variant?: ButtonVariants["variant"];
|
||||
className?: string;
|
||||
size?: "xs" | "sm";
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -51,6 +55,10 @@ export function SelectDropdown({
|
||||
searchPlaceholder = "Search...",
|
||||
isLoading = false,
|
||||
variant = "input",
|
||||
className,
|
||||
size = "sm",
|
||||
children,
|
||||
maxHeight,
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
@@ -79,6 +87,46 @@ export function SelectDropdown({
|
||||
});
|
||||
}, [options, debouncedSearch]);
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Skeleton width={20} />
|
||||
<Skeleton width={100} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectedItem = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
{selected?.icon && <selected.icon size={14} width={14} />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{selected?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlaceholderItem = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{placeholder}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -91,45 +139,26 @@ export function SelectDropdown({
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild={true} disabled={disabled || isLoading}>
|
||||
<Button
|
||||
variant={variant}
|
||||
disabled={disabled || isLoading}
|
||||
ref={inputRef}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"w-full flex justify-between items-center gap-2"}>
|
||||
{isLoading ? (
|
||||
<div className={"flex gap-2"}>
|
||||
<Skeleton width={20} />
|
||||
<Skeleton width={100} />
|
||||
<PopoverTrigger asChild={!children} disabled={disabled || isLoading}>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<Button
|
||||
variant={variant}
|
||||
disabled={disabled || isLoading}
|
||||
ref={inputRef}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
<div className={"w-full flex justify-between items-center gap-2"}>
|
||||
{isLoading && <Loading />}
|
||||
{!isLoading && selected && <SelectedItem />}
|
||||
{!isLoading && !selected && <PlaceholderItem />}
|
||||
<div className={"pl-2"}>
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</div>
|
||||
) : selected ? (
|
||||
<React.Fragment>
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
{selected?.icon && <selected.icon size={14} width={14} />}
|
||||
<div className={"flex flex-col text-sm font-medium"}>
|
||||
<span className={"text-nb-gray-200"}>
|
||||
{selected?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
<div className={"flex flex-col text-sm font-medium"}>
|
||||
<span className={"text-nb-gray-200"}>{placeholder}</span>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<div className={"pl-2"}>
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
|
||||
@@ -164,18 +193,22 @@ export function SelectDropdown({
|
||||
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"max-h-[380px] overflow-y-auto flex flex-col gap-1 pl-2 pb-2 pr-3",
|
||||
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
|
||||
!showSearch && "pt-2",
|
||||
)}
|
||||
style={{
|
||||
maxHeight: maxHeight ?? 380,
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={"grid grid-cols-1 gap-1"}>
|
||||
<div className={"grid grid-cols-1 gap-1 pb-2"}>
|
||||
{filteredItems.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -192,10 +225,12 @@ const SelectDropdownItem = ({
|
||||
option,
|
||||
toggle,
|
||||
showValue = false,
|
||||
size = "sm",
|
||||
}: {
|
||||
option: SelectOption;
|
||||
toggle: (value: string) => void;
|
||||
showValue?: boolean;
|
||||
size: "xs" | "sm";
|
||||
}) => {
|
||||
const value = option.value || "" + option.label || "";
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
@@ -221,13 +256,20 @@ const SelectDropdownItem = ({
|
||||
>
|
||||
<div className={"flex items-center gap-2.5 p-1"}>
|
||||
{option.icon && <option.icon size={14} width={14} />}
|
||||
<div className={"flex flex-col text-sm font-medium"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{showValue && (
|
||||
<div className={"flex items-center gap-2.5 p-1"}>
|
||||
<Paragraph className={cn("text-sm text-right")}>
|
||||
<Paragraph
|
||||
className={cn("text-sm text-right", size === "xs" && "text-xs")}
|
||||
>
|
||||
{option.value}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -38,15 +37,9 @@ export const SelectDropdownSearchInput = forwardRef<HTMLInputElement, Props>(
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={"absolute right-0 top-0 h-full flex items-center pr-4"}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
className={"h-[44px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled == true ? true : disabled}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
className={"h-[44px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
description?: string;
|
||||
button?: React.ReactNode;
|
||||
learnMore?: React.ReactNode;
|
||||
showBackground?: boolean;
|
||||
};
|
||||
|
||||
export default function GetStartedTest({
|
||||
@@ -18,28 +19,33 @@ export default function GetStartedTest({
|
||||
description,
|
||||
button,
|
||||
learnMore,
|
||||
showBackground = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={"px-8 mt-8"}>
|
||||
<Card className={"w-full relative overflow-hidden"}>
|
||||
<div
|
||||
className={
|
||||
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
|
||||
}
|
||||
></div>
|
||||
<div
|
||||
className={
|
||||
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
</div>
|
||||
</div>
|
||||
{showBackground && (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
|
||||
}
|
||||
></div>
|
||||
<div
|
||||
className={
|
||||
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={"w-full h-full z-20 relative left-0 top-0 flex py-8"}>
|
||||
<div className={"inline-flex justify-center w-full"}>
|
||||
<div>
|
||||
|
||||
@@ -11,9 +11,11 @@ import { useGroupIdentification } from "@/modules/groups/useGroupIdentification"
|
||||
export const GroupBadgeIcon = ({
|
||||
id,
|
||||
issued,
|
||||
size = 12,
|
||||
}: {
|
||||
id?: string;
|
||||
issued?: GroupIssued;
|
||||
size?: number;
|
||||
}) => {
|
||||
const { groups } = useGroups();
|
||||
const group = groups?.find((g) => g.id === id);
|
||||
@@ -22,11 +24,12 @@ export const GroupBadgeIcon = ({
|
||||
useGroupIdentification({ id, issued: issued ?? group?.issued });
|
||||
|
||||
if (isGoogleGroup)
|
||||
return <GoogleIcon size={11} className={"shrink-0 mr-0.5"} />;
|
||||
return <GoogleIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isAzureGroup)
|
||||
return <EntraIcon size={13} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOktaGroup) return <OktaIcon size={11} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJWTGroup) return <JWTIcon size={12} className={"shrink-0"} />;
|
||||
return <EntraIcon size={size + 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isOktaGroup)
|
||||
return <OktaIcon size={size - 1} className={"shrink-0 mr-0.5"} />;
|
||||
if (isJWTGroup) return <JWTIcon size={size} className={"shrink-0"} />;
|
||||
|
||||
return <FolderGit2 size={12} className={"shrink-0"} />;
|
||||
return <FolderGit2 size={size} className={"shrink-0"} />;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { memo } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
|
||||
const MemoizedNetBirdIcon = () => {
|
||||
return <NetBirdIcon size={16} />;
|
||||
return <NetBirdIcon size={14} />;
|
||||
};
|
||||
|
||||
export default memo(MemoizedNetBirdIcon);
|
||||
|
||||
@@ -8,12 +8,12 @@ export const NotificationCountBadge = ({ count = 0 }: Props) => {
|
||||
return count ? (
|
||||
<div
|
||||
className={cn(
|
||||
count <= 9 ? "w-5 h-5" : "py-2.5 px-2",
|
||||
"relative bg-netbird flex items-center justify-center rounded-full text-white !leading-[0] text-xs font-semibold",
|
||||
count <= 9 ? "w-4 h-4" : "py-2 px-1.5",
|
||||
"relative bg-netbird flex items-center justify-center rounded-full text-white !leading-[0] text-[0.6rem] font-semibold",
|
||||
)}
|
||||
>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-full w-full rounded-full bg-netbird opacity-20"></span>
|
||||
{count || 0}
|
||||
<span className={"relative -left-[0.5px]"}>{count || 0}</span>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -34,31 +34,34 @@ export default function PolicyDirection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
|
||||
const isNetworkResource =
|
||||
!!destinationResource && destinationResource?.type !== "peer";
|
||||
|
||||
const topBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "blueDark";
|
||||
if (isNetworkResource) return "blueDark";
|
||||
if (value === "bi") return "green";
|
||||
if (value === "in") return "blueDark";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
const topArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-sky-500";
|
||||
if (isNetworkResource) return "fill-sky-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
if (value === "in") return "fill-sky-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
const bottomBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "gray";
|
||||
if (isNetworkResource) return "gray";
|
||||
if (value === "bi") return "green";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
const bottomArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-gray-500";
|
||||
if (isNetworkResource) return "fill-gray-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
}, [value, isNetworkResource]);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,9 +4,13 @@ import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
peer?: Peer;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
showX?: boolean;
|
||||
children?: React.ReactNode;
|
||||
@@ -15,35 +19,44 @@ type Props = {
|
||||
export default function ResourceBadge({
|
||||
onClick,
|
||||
resource,
|
||||
peer,
|
||||
showX = false,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
if (!resource) return;
|
||||
if (!resource && !peer) return;
|
||||
|
||||
const isPeer = !!peer;
|
||||
const key = resource ? resource.id || resource?.name : peer?.id || peer?.name;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={resource.id || resource?.name}
|
||||
key={key}
|
||||
useHover={true}
|
||||
data-cy={"resource-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap",
|
||||
className,
|
||||
isPeer && "px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{resource.type === "host" && (
|
||||
<WorkflowIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "domain" && (
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "subnet" && (
|
||||
<NetworkIcon size={12} className={"shrink-0"} />
|
||||
{isPeer ? (
|
||||
<>
|
||||
<PeerOperatingSystemIcon os={peer?.os} />
|
||||
<TruncatedText text={peer?.name || ""} maxChars={20} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ResourceIcon type={resource?.type || ""} />
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{showX && (
|
||||
<XIcon
|
||||
@@ -56,3 +69,16 @@ export default function ResourceBadge({
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ResourceIcon = ({ type }: { type: string }) => {
|
||||
switch (type) {
|
||||
case "host":
|
||||
return <WorkflowIcon size={12} className={"shrink-0"} />;
|
||||
case "domain":
|
||||
return <GlobeIcon size={12} className={"shrink-0"} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={12} className={"shrink-0"} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function TextWithTooltip({
|
||||
<FullTooltip
|
||||
disabled={charCount <= maxChars || hideTooltip}
|
||||
interactive={false}
|
||||
className={"truncate w-full min-w-0"}
|
||||
className={"truncate w-auto min-w-0"}
|
||||
skipDelayDuration={350}
|
||||
delayDuration={200}
|
||||
content={
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import SkeletonPeerDetail from "@components/skeletons/SkeletonPeerDetail";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { PeerSSHInstructions } from "@/modules/peer/PeerSSHInstructions";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
peer: Peer;
|
||||
isPeerDetailPage?: boolean;
|
||||
};
|
||||
|
||||
const PeerContext = React.createContext(
|
||||
@@ -28,18 +31,25 @@ const PeerContext = React.createContext(
|
||||
approval_required?: boolean;
|
||||
ip?: string;
|
||||
}) => Promise<Peer>;
|
||||
openSSHDialog: () => Promise<boolean>;
|
||||
toggleSSH: (newState: boolean) => Promise<void>;
|
||||
setSSHInstructionsModal: (open: boolean) => void;
|
||||
deletePeer: () => void;
|
||||
isLoading: boolean;
|
||||
},
|
||||
);
|
||||
|
||||
export default function PeerProvider({ children, peer }: Props) {
|
||||
export default function PeerProvider({
|
||||
children,
|
||||
peer,
|
||||
isPeerDetailPage = false,
|
||||
}: Props) {
|
||||
const user = usePeerUser(peer);
|
||||
const { peerGroups, isLoading } = usePeerGroups(peer);
|
||||
const peerRequest = useApiCall<Peer>("/peers", true);
|
||||
const { confirm } = useDialog();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const [sshInstructionsModal, setSSHInstructionsModal] = useState(false);
|
||||
|
||||
const deletePeer = async () => {
|
||||
const choice = await confirm({
|
||||
@@ -94,14 +104,20 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const openSSHDialog = async (): Promise<boolean> => {
|
||||
return await confirm({
|
||||
title: `Enable SSH Server for ${peer.name}?`,
|
||||
description:
|
||||
"Experimental feature. Enabling this option allows remote SSH access to this machine from other connected network participants.",
|
||||
confirmText: "Enable",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
const toggleSSH = async (enable: boolean) => {
|
||||
if (!permission.peers.update) return;
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: enable
|
||||
? "SSH Access successfully enabled"
|
||||
: "SSH Access successfully disabled",
|
||||
promise: update({ ssh: enable }).then(() => {
|
||||
isPeerDetailPage ? mutate(`/peers/${peer.id}`) : mutate("/peers");
|
||||
setSSHInstructionsModal(false);
|
||||
}),
|
||||
loadingMessage: enable
|
||||
? "Enabling SSH Access..."
|
||||
: "Disabling SSH Access...",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -112,16 +128,25 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
peerGroups,
|
||||
user,
|
||||
update,
|
||||
openSSHDialog,
|
||||
toggleSSH,
|
||||
setSSHInstructionsModal,
|
||||
deletePeer,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{sshInstructionsModal && (
|
||||
<PeerSSHInstructions
|
||||
open={sshInstructionsModal}
|
||||
onOpenChange={setSSHInstructionsModal}
|
||||
onSuccess={() => toggleSSH(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PeerContext.Provider>
|
||||
) : (
|
||||
) : isPeerDetailPage ? (
|
||||
<SkeletonPeerDetail />
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
|
||||
: toUpdate.access_control_groups ??
|
||||
route.access_control_groups ??
|
||||
undefined,
|
||||
skip_auto_apply: toUpdate.skip_auto_apply ?? route.skip_auto_apply ?? true,
|
||||
},
|
||||
`/${route.id}`,
|
||||
)
|
||||
@@ -94,6 +95,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
|
||||
masquerade: route.masquerade,
|
||||
groups: route.groups || [],
|
||||
access_control_groups: route?.access_control_groups || undefined,
|
||||
skip_auto_apply: route.skip_auto_apply ?? true,
|
||||
})
|
||||
.then((route) => {
|
||||
mutate("/routes");
|
||||
|
||||
@@ -68,12 +68,17 @@ const UserProfileProvider = ({ children }: Props) => {
|
||||
};
|
||||
}, [loggedInUser]);
|
||||
|
||||
return !isLoading && loggedInUser ? (
|
||||
// Show loading only when we're still loading and don't have user data
|
||||
if (isLoading || !loggedInUser) {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
// For blocked or pending approval users, we still need to provide the context
|
||||
// so they can access their user data on the blocked page
|
||||
return (
|
||||
<UserProfileContext.Provider value={data}>
|
||||
<PermissionsProvider user={loggedInUser}>{children}</PermissionsProvider>
|
||||
</UserProfileContext.Provider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function useOperatingSystem() {
|
||||
* Falls back to Linux if the operating system is not recognized
|
||||
*/
|
||||
export const getOperatingSystem = (os: string) => {
|
||||
if (!os) return OperatingSystem.LINUX as const;
|
||||
if (os.toLowerCase().includes("freebsd"))
|
||||
return OperatingSystem.FREEBSD as const;
|
||||
if (os.toLowerCase().includes("darwin"))
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Account {
|
||||
settings: {
|
||||
extra: {
|
||||
peer_approval_enabled: boolean;
|
||||
user_approval_required: boolean;
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_login_expiration: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Peer {
|
||||
name: string;
|
||||
ip: string;
|
||||
connected: boolean;
|
||||
created_at?: Date;
|
||||
last_seen: Date;
|
||||
os: string;
|
||||
version: string;
|
||||
@@ -15,6 +16,7 @@ export interface Peer {
|
||||
user_id?: string;
|
||||
user?: User;
|
||||
ui_version?: string;
|
||||
kernel_version?: string;
|
||||
dns_label: string;
|
||||
extra_dns_labels?: string[];
|
||||
last_login: Date;
|
||||
@@ -26,4 +28,5 @@ export interface Peer {
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
ephemeral: boolean;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface PortRange {
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
type?: "domain" | "host" | "subnet" | "peer";
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Route {
|
||||
groups: string[];
|
||||
keep_route?: boolean;
|
||||
access_control_groups?: string[];
|
||||
skip_auto_apply?: boolean;
|
||||
// Frontend only
|
||||
peer_groups?: string[];
|
||||
routesGroups?: string[];
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
is_current?: boolean;
|
||||
is_service_user?: boolean;
|
||||
is_blocked?: boolean;
|
||||
pending_approval?: boolean;
|
||||
last_login?: Date;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function NavbarWithDropdown() {
|
||||
<AnnouncementBanner />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray/50 backdrop-blur-lg sm:px-6",
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
|
||||
"flex justify-between items-center transition-all",
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import DocsIcon from "@/assets/icons/DocsIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
@@ -67,6 +70,23 @@ export default function Navigation({
|
||||
>
|
||||
<div>
|
||||
<SidebarItemGroup>
|
||||
<SidebarItem
|
||||
icon={<ControlCenterIcon size={16} />}
|
||||
label={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
Control Center
|
||||
<SmallBadge
|
||||
text={"Beta"}
|
||||
variant={"sky"}
|
||||
className={"text-[8px] leading-none py-[3px] px-[5px]"}
|
||||
textClassName={"top-0"}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
href={"/control-center"}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
icon={<PeerIcon />}
|
||||
label="Peers"
|
||||
|
||||
@@ -156,6 +156,8 @@ export function AccessControlModalContent({
|
||||
submit,
|
||||
isPostureChecksLoading,
|
||||
getPolicyData,
|
||||
sourceResource,
|
||||
setSourceResource,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
@@ -176,15 +178,17 @@ export function AccessControlModalContent({
|
||||
return "policy";
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, destinationResource]);
|
||||
const canContinueToPostureChecks = useMemo(() => {
|
||||
const hasSource = sourceGroups.length > 0 || !!sourceResource;
|
||||
const hasDestination =
|
||||
destinationGroups.length > 0 || !!destinationResource;
|
||||
return hasSource && hasDestination;
|
||||
}, [sourceGroups, destinationGroups, destinationResource, sourceResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
if (continuePostureChecksDisabled) return true;
|
||||
}, [name, continuePostureChecksDisabled]);
|
||||
if (!canContinueToPostureChecks) return true;
|
||||
}, [name, canContinueToPostureChecks]);
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
@@ -220,11 +224,8 @@ export function AccessControlModalContent({
|
||||
<ArrowRightLeft size={16} />
|
||||
Policy
|
||||
</TabsTrigger>
|
||||
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
|
||||
<TabsTrigger
|
||||
value={"general"}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
<PostureCheckTabTrigger disabled={!canContinueToPostureChecks} />
|
||||
<TabsTrigger value={"general"} disabled={!canContinueToPostureChecks}>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
@@ -283,14 +284,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select source(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={false}
|
||||
showPeers={true}
|
||||
showResourceCounter={false}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
popoverWidth={500}
|
||||
showRoutes={false}
|
||||
onChange={setSourceGroups}
|
||||
values={sourceGroups}
|
||||
onChange={setSourceGroups}
|
||||
resource={sourceResource}
|
||||
onResourceChange={setSourceResource}
|
||||
saveGroupAssignments={useSave}
|
||||
showResourceCounter={false}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
@@ -310,17 +316,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"destination-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select destination(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={true}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
popoverWidth={500}
|
||||
onChange={setDestinationGroups}
|
||||
values={destinationGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
onChange={setDestinationGroups}
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
showResources={true}
|
||||
placeholder={"Select destination(s)..."}
|
||||
saveGroupAssignments={useSave}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
@@ -453,35 +461,36 @@ export function AccessControlModalContent({
|
||||
{!policy ? (
|
||||
<>
|
||||
{tab == "policy" && (
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={!canContinueToPostureChecks}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button variant={"secondary"} onClick={() => setTab("policy")}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "policy" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("policy")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={!canContinueToPostureChecks}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab == "general" && (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -20,30 +18,13 @@ export default function AccessControlDestinationsCell({
|
||||
|
||||
if (firstRule?.destinationResource) {
|
||||
return (
|
||||
<AccessControlDestinationResourceCell
|
||||
resource={firstRule.destinationResource}
|
||||
/>
|
||||
<AccessControlResourceCell resource={firstRule.destinationResource} />
|
||||
);
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
) : null;
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
|
||||
const AccessControlDestinationResourceCell = ({
|
||||
resource,
|
||||
}: {
|
||||
resource: PolicyRuleResource;
|
||||
}) => {
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
if (isLoading) return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge resource={resources?.find((r) => r.id === resource.id)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
resource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export const AccessControlResourceCell = ({ resource }: Props) => {
|
||||
const { data: resources, isLoading: isLoadingResources } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
const { data: peers, isLoading: isLoadingPeers } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const isPeer = resource?.type === "peer";
|
||||
const peer = peers?.find((p) => p.id === resource?.id);
|
||||
|
||||
if ((isPeer && isLoadingPeers) || (!isPeer && isLoadingResources))
|
||||
return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge
|
||||
resource={resources?.find((r) => r.id === resource?.id)}
|
||||
peer={peer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import React, { useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -12,7 +14,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
if (firstRule?.sourceResource) {
|
||||
return <AccessControlResourceCell resource={firstRule.sourceResource} />;
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.sources as Group[]} />
|
||||
) : null;
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -9,9 +10,9 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -200,6 +201,41 @@ export default function AccessControlTable({
|
||||
const [currentRow, setCurrentRow] = useState<Policy>();
|
||||
const [currentCellClicked, setCurrentCellClicked] = useState("");
|
||||
|
||||
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
|
||||
|
||||
const withTemporaryPolicies = useCallback(
|
||||
(condition: boolean) =>
|
||||
policies?.filter((policy) =>
|
||||
condition
|
||||
? policy?.name?.startsWith("Temporary") &&
|
||||
policy?.name?.endsWith("client") &&
|
||||
policy?.description?.startsWith("Temporary") &&
|
||||
policy?.description?.endsWith("client")
|
||||
: !(
|
||||
policy?.name?.startsWith("Temporary") &&
|
||||
policy?.name?.endsWith("client") &&
|
||||
policy?.description?.startsWith("Temporary") &&
|
||||
policy?.description?.endsWith("client")
|
||||
),
|
||||
) ?? [],
|
||||
[policies],
|
||||
);
|
||||
|
||||
const tempPolicies = useMemo(
|
||||
() => withTemporaryPolicies(true),
|
||||
[withTemporaryPolicies],
|
||||
);
|
||||
const regularPolicies = useMemo(
|
||||
() => withTemporaryPolicies(false),
|
||||
[withTemporaryPolicies],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTemporaryPolicies && tempPolicies?.length === 0) {
|
||||
setShowTemporaryPolicies(false);
|
||||
}
|
||||
}, [showTemporaryPolicies, tempPolicies]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editModal && currentRow && (
|
||||
@@ -232,8 +268,9 @@ export default function AccessControlTable({
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
temporary: false,
|
||||
}}
|
||||
data={policies}
|
||||
data={showTemporaryPolicies ? tempPolicies : regularPolicies}
|
||||
onRowClick={(row, cell) => {
|
||||
setCurrentRow(row.original);
|
||||
setEditModal(true);
|
||||
@@ -301,65 +338,90 @@ export default function AccessControlTable({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={policies?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
{(table) => {
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup disabled={policies?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Active
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(false);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Inactive
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
/>
|
||||
|
||||
{tempPolicies?.length > 0 && (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-sm text-xs"}>
|
||||
Show temporary policies created by the NetBird browser
|
||||
client. These policies are ephemeral and will be deleted
|
||||
automatically after a short period of time.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={"h-[44px]"}
|
||||
variant={showTemporaryPolicies ? "tertiary" : "secondary"}
|
||||
onClick={() => {
|
||||
setShowTemporaryPolicies(!showTemporaryPolicies);
|
||||
}}
|
||||
>
|
||||
<ClockFadingIcon size={16} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
)}
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={policies?.length == 0}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
mutate("/policies").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Active
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(false);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Inactive
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={policies?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={policies?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/policies").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -126,6 +126,10 @@ export const useAccessControl = ({
|
||||
: initialDestinationGroups ?? [],
|
||||
});
|
||||
|
||||
const [sourceResource, setSourceResource] = useState(
|
||||
firstRule?.sourceResource,
|
||||
);
|
||||
|
||||
const [destinationResource, setDestinationResource] = useState(
|
||||
firstRule?.destinationResource,
|
||||
);
|
||||
@@ -163,8 +167,9 @@ export const useAccessControl = ({
|
||||
bidirectional: direction == "bi",
|
||||
description,
|
||||
name,
|
||||
sources: sources,
|
||||
sources: sourceResource ? undefined : sources,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
sourceResource: sourceResource || undefined,
|
||||
destinationResource: destinationResource || undefined,
|
||||
action: "accept",
|
||||
protocol,
|
||||
@@ -241,8 +246,9 @@ export const useAccessControl = ({
|
||||
action: "accept",
|
||||
protocol,
|
||||
enabled,
|
||||
sources,
|
||||
sources: sourceResource ? undefined : sources,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
sourceResource: sourceResource || undefined,
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
@@ -254,9 +260,9 @@ export const useAccessControl = ({
|
||||
updatePolicy(
|
||||
policy,
|
||||
policyObj,
|
||||
() => {
|
||||
(p) => {
|
||||
mutate("/policies");
|
||||
onSuccess && onSuccess(policy);
|
||||
onSuccess && onSuccess(p);
|
||||
},
|
||||
"The policy was successfully saved",
|
||||
);
|
||||
@@ -276,7 +282,10 @@ export const useAccessControl = ({
|
||||
const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp";
|
||||
const portDisabled = !hasPortSupport(protocol);
|
||||
|
||||
const isDestinationPeer = destinationResource?.type === "peer";
|
||||
|
||||
const destinationHasResources = useMemo(() => {
|
||||
if (isDestinationPeer) return false;
|
||||
if (destinationResource) return true;
|
||||
|
||||
return destinationGroups.some((group) => {
|
||||
@@ -288,9 +297,10 @@ export const useAccessControl = ({
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [destinationGroups, destinationResource]);
|
||||
}, [destinationGroups, destinationResource, isDestinationPeer]);
|
||||
|
||||
const destinationOnlyResources = useMemo(() => {
|
||||
if (isDestinationPeer) return false;
|
||||
if (destinationResource) return true;
|
||||
|
||||
return (
|
||||
@@ -312,13 +322,13 @@ export const useAccessControl = ({
|
||||
return hasResources && !hasPeers;
|
||||
})
|
||||
);
|
||||
}, [destinationGroups, destinationResource]);
|
||||
}, [destinationGroups, destinationResource, isDestinationPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (destinationOnlyResources && direction !== "in") {
|
||||
if (destinationOnlyResources && direction !== "in" && !isDestinationPeer) {
|
||||
setDirection("in");
|
||||
}
|
||||
}, [destinationOnlyResources, direction, setDirection]);
|
||||
}, [destinationOnlyResources, direction, setDirection, isDestinationPeer]);
|
||||
|
||||
return {
|
||||
protocol,
|
||||
@@ -345,6 +355,8 @@ export const useAccessControl = ({
|
||||
getPolicyData,
|
||||
portDisabled,
|
||||
isPostureChecksLoading,
|
||||
sourceResource,
|
||||
setSourceResource,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
destinationHasResources,
|
||||
|
||||
@@ -253,6 +253,22 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.approve")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
User <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value> was approved
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.reject")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
User <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value> was rejected
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Service User
|
||||
*/
|
||||
@@ -349,6 +365,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "peer.user.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
|
||||
with the NetBird IP <Value>{m.ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
@@ -653,6 +677,20 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Limit traffic event group <Value>{m.group_name}</Value> removed
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Limit traffic event group <Value>{m.group_name}</Value> added
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2.5 items-center"}>
|
||||
<span className={"mb-[1px]"}>{event.activity}</span>
|
||||
|
||||
@@ -18,7 +18,9 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
|
||||
// Error actions
|
||||
delete: ActionStatus.ERROR,
|
||||
revoke: ActionStatus.ERROR,
|
||||
remove: ActionStatus.ERROR,
|
||||
block: ActionStatus.ERROR,
|
||||
reject: ActionStatus.ERROR,
|
||||
|
||||
// Warning actions
|
||||
overuse: ActionStatus.WARNING,
|
||||
|
||||
@@ -37,12 +37,13 @@ export default function ActiveInactiveRow({
|
||||
<div className={"flex gap-2.5 items-start"}>
|
||||
<CircleIcon
|
||||
active={active}
|
||||
size={8}
|
||||
inactiveDot={inactiveDot}
|
||||
className={"mt-[0.34rem] shrink-0"}
|
||||
className={"mt-[0.45rem] shrink-0"}
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0"}>
|
||||
<div
|
||||
className={"font-medium flex gap-2 items-center justify-center"}
|
||||
className={"font-medium flex gap-2 items-center justify-start"}
|
||||
>
|
||||
<TextWithTooltip text={text as string} maxChars={25} />
|
||||
{additionalInfo}
|
||||
|
||||
48
src/modules/control-center/FlowSelector.tsx
Normal file
48
src/modules/control-center/FlowSelector.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import { FolderGit2, MonitorSmartphoneIcon, NetworkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
export enum FlowView {
|
||||
NETWORKS = "networks",
|
||||
GROUPS = "groups",
|
||||
PEERS = "peers",
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value?: FlowView;
|
||||
onChange?: (value: FlowView) => void;
|
||||
};
|
||||
|
||||
export const FlowSelector = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<SegmentedTabs value={value} onChange={(v) => onChange?.(v as FlowView)}>
|
||||
<SegmentedTabs.List
|
||||
className={
|
||||
"border-b rounded-b-lg text-sm font-medium bg-nb-gray-930 p-1"
|
||||
}
|
||||
>
|
||||
<SegmentedTabs.Trigger
|
||||
value={FlowView.PEERS}
|
||||
className={"text-xs px-3 py-1"}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={12} />
|
||||
Peers
|
||||
</SegmentedTabs.Trigger>
|
||||
<SegmentedTabs.Trigger
|
||||
value={FlowView.GROUPS}
|
||||
className={"text-xs px-3 py-1"}
|
||||
>
|
||||
<FolderGit2 size={12} />
|
||||
Groups
|
||||
</SegmentedTabs.Trigger>
|
||||
<SegmentedTabs.Trigger
|
||||
value={FlowView.NETWORKS}
|
||||
className={"text-xs px-3 py-[0.45rem]"}
|
||||
>
|
||||
<NetworkIcon size={12} />
|
||||
Networks
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
</SegmentedTabs>
|
||||
);
|
||||
};
|
||||
48
src/modules/control-center/NetworkRoutingPeerCount.tsx
Normal file
48
src/modules/control-center/NetworkRoutingPeerCount.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Button from "@components/Button";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { Network, NetworkRouter } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
network: Network;
|
||||
};
|
||||
|
||||
export const NetworkRoutingPeerCount = ({ network }: Props) => {
|
||||
const { data: routers, isLoading: isRoutersLoading } =
|
||||
useFetchApi<NetworkRouter[]>("/networks/routers");
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const routingPeerStatusColor = useMemo(() => {
|
||||
if (!network) return "bg-nb-gray-500";
|
||||
const routerCount = network.routers?.length || 0;
|
||||
if (routerCount === 0) return "bg-nb-gray-500";
|
||||
if (routerCount === 1) return "bg-yellow-400";
|
||||
if (routerCount > 1) return "bg-green-400";
|
||||
return "bg-nb-gray-500";
|
||||
}, [network]);
|
||||
|
||||
const networkRouters = useMemo(() => {
|
||||
if (!network || !peers) return [];
|
||||
const routerIds = network?.routers?.map((r) => r) || [];
|
||||
return routers?.filter((r) => routerIds.includes(r.id)) || [];
|
||||
}, [network, peers, routers]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"!bg-nb-gray-930 !text-nb-gray-300 cursor-default"}
|
||||
>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
className={cn("shrink-0 block", routingPeerStatusColor)}
|
||||
/>
|
||||
{network.routers?.length || 0} Routing Peer(s)
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
125
src/modules/control-center/edges/AnimatedLine.tsx
Normal file
125
src/modules/control-center/edges/AnimatedLine.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Edge, useInternalNode } from "@xyflow/react";
|
||||
import React from "react";
|
||||
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
|
||||
|
||||
type AnimatedLineProps = Edge<
|
||||
{
|
||||
label?: string;
|
||||
color?: string;
|
||||
},
|
||||
"animated-line"
|
||||
>;
|
||||
|
||||
function AnimatedLine({ id, source, target, data }: AnimatedLineProps) {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
const { sx, sy, tx, ty } = getEdgeParams(sourceNode, targetNode);
|
||||
|
||||
const labelX = (sx + tx) / 2;
|
||||
const labelY = (sy + ty) / 2;
|
||||
|
||||
let angle = Math.atan2(ty - sy, tx - sx) * (180 / Math.PI);
|
||||
if (angle < -90 || angle > 90) {
|
||||
angle += 180;
|
||||
}
|
||||
|
||||
const label = data?.label || "";
|
||||
const hasLabel = label?.length > 0;
|
||||
const fontSize = 12;
|
||||
const paddingX = hasLabel ? 2 : 0;
|
||||
const paddingY = hasLabel ? 2 : 0;
|
||||
|
||||
const gapWidth = hasLabel ? 4 : 0;
|
||||
const labelTextWidth = label.length * 7;
|
||||
|
||||
const labelWidth = gapWidth + labelTextWidth + paddingX * 2;
|
||||
const labelHeight = fontSize + paddingY * 2;
|
||||
|
||||
const dx = tx - sx;
|
||||
const dy = ty - sy;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const gap = labelWidth / 2;
|
||||
const nx = dx / length;
|
||||
const ny = dy / length;
|
||||
|
||||
const preLabelX = labelX - nx * gap;
|
||||
const preLabelY = labelY - ny * gap;
|
||||
|
||||
const postLabelX = labelX + nx * gap;
|
||||
const postLabelY = labelY + ny * gap;
|
||||
|
||||
const color = data?.color || "#0e9f6e";
|
||||
|
||||
return (
|
||||
<>
|
||||
<line
|
||||
x1={sx}
|
||||
y1={sy}
|
||||
x2={preLabelX}
|
||||
y2={preLabelY}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5, 5"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="20"
|
||||
to="0"
|
||||
dur="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
<line
|
||||
x1={postLabelX}
|
||||
y1={postLabelY}
|
||||
x2={tx}
|
||||
y2={ty}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5, 5"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="20"
|
||||
to="0"
|
||||
dur="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
{label && hasLabel && (
|
||||
<foreignObject
|
||||
x={labelX - labelWidth / 2}
|
||||
y={labelY - labelHeight / 2}
|
||||
width={labelWidth}
|
||||
height={labelHeight}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
fontSize,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: `${paddingY}px ${paddingX}px`,
|
||||
transform: `rotate(${angle}deg)`,
|
||||
transformOrigin: "center center",
|
||||
boxSizing: "border-box",
|
||||
background: "none",
|
||||
}}
|
||||
className={
|
||||
"flex items-center justify-center gap-1 select-none pointer-events-none z-10 text-green-50"
|
||||
}
|
||||
>
|
||||
<div className={"whitespace-nowrap"}>{label}</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnimatedLine;
|
||||
70
src/modules/control-center/edges/BidirectionalEdges.tsx
Normal file
70
src/modules/control-center/edges/BidirectionalEdges.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
|
||||
import React from "react";
|
||||
|
||||
export function BidirectionalEdges({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) {
|
||||
const [forwardPath] = getSmoothStepPath({
|
||||
sourceX: sourceX - 5,
|
||||
sourceY: sourceY - 5,
|
||||
sourcePosition,
|
||||
targetX: targetX + 15,
|
||||
targetY: targetY - 5,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const [backwardPath] = getSmoothStepPath({
|
||||
sourceX: targetX + 5,
|
||||
sourceY: targetY + 5,
|
||||
sourcePosition: targetPosition,
|
||||
targetX: sourceX - 15,
|
||||
targetY: sourceY + 5,
|
||||
targetPosition: sourcePosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={`${id}-forward`}
|
||||
path={forwardPath}
|
||||
style={{
|
||||
strokeWidth: 2,
|
||||
stroke: "#0e9f6e",
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="20"
|
||||
to="0"
|
||||
dur="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</BaseEdge>
|
||||
|
||||
<BaseEdge
|
||||
id={`${id}-backward`}
|
||||
path={backwardPath}
|
||||
style={{
|
||||
strokeWidth: 2,
|
||||
stroke: "#0e9f6e",
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="20"
|
||||
to="0"
|
||||
dur="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</BaseEdge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
src/modules/control-center/edges/DirectionIn.tsx
Normal file
92
src/modules/control-center/edges/DirectionIn.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getSimpleBezierPath,
|
||||
getSmoothStepPath,
|
||||
getStraightPath,
|
||||
} from "@xyflow/react";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
type: "smoothstep" | "straight" | "bezier";
|
||||
};
|
||||
} & EdgeProps;
|
||||
|
||||
export function DirectionIn({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
}: Props) {
|
||||
const { enabled, type = "straight" } = data;
|
||||
|
||||
const getPath = () => {
|
||||
switch (type) {
|
||||
case "straight":
|
||||
return getStraightPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
});
|
||||
case "bezier":
|
||||
return getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
case "smoothstep":
|
||||
return getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
default:
|
||||
return getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [edgePath] = getPath();
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
opacity: enabled ? 1 : 0.6,
|
||||
strokeWidth: 2,
|
||||
stroke: enabled ? "#0e9f6e" : "#787878",
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
>
|
||||
{enabled && (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="20"
|
||||
to="0"
|
||||
dur="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</BaseEdge>
|
||||
);
|
||||
}
|
||||
53
src/modules/control-center/edges/FloatingEdge.tsx
Normal file
53
src/modules/control-center/edges/FloatingEdge.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeProps,
|
||||
getBezierPath,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
import React from "react";
|
||||
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
|
||||
|
||||
function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode,
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetPosition: targetPos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeWidth: 2,
|
||||
stroke: "#0e9f6e",
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="20"
|
||||
to="0"
|
||||
dur="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</BaseEdge>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingEdge;
|
||||
45
src/modules/control-center/edges/SimpleConnection.tsx
Normal file
45
src/modules/control-center/edges/SimpleConnection.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BaseEdge, type EdgeProps, getSimpleBezierPath } from "@xyflow/react";
|
||||
import React from "react";
|
||||
import { useSourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
};
|
||||
} & EdgeProps;
|
||||
|
||||
export function SimpleConnection({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
source,
|
||||
}: Props) {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const enabled = useSourceGroupEnabled(source);
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeWidth: 1.5,
|
||||
stroke: "#595959",
|
||||
strokeDasharray: "0, 0",
|
||||
opacity: enabled ? 1 : 0.6,
|
||||
}}
|
||||
></BaseEdge>
|
||||
);
|
||||
}
|
||||
111
src/modules/control-center/nodes/DeviceCard.tsx
Normal file
111
src/modules/control-center/nodes/DeviceCard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type DeviceCardProps = {
|
||||
device?: Peer;
|
||||
resource?: NetworkResource;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DeviceCard = ({
|
||||
device,
|
||||
resource,
|
||||
className,
|
||||
}: DeviceCardProps) => {
|
||||
if (!device && !resource) return;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-850 transition-all",
|
||||
"group-hover:bg-nb-gray-800 relative",
|
||||
)}
|
||||
>
|
||||
{device && <PeerOSIcon os={device.os} />}
|
||||
{resource?.type && <ResourceIcon type={resource.type} />}
|
||||
|
||||
{device?.country_code && (
|
||||
<div className={"absolute -bottom-[4px] -right-[4px]"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border-[3px] shrink-0",
|
||||
"border-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
<RoundedFlag country={device?.country_code} size={10} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
|
||||
<span
|
||||
className={
|
||||
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<TruncatedText
|
||||
text={device?.name || resource?.name || "Unknown"}
|
||||
maxWidth={"150px"}
|
||||
hideTooltip={true}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
|
||||
}
|
||||
>
|
||||
{device?.ip || resource?.address}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOSIcon = ({ os }: { os: string }) => {
|
||||
const osType = getOperatingSystem(os);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
osType === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={os} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceIcon = ({
|
||||
type,
|
||||
size = 15,
|
||||
}: {
|
||||
type: "domain" | "host" | "subnet";
|
||||
size?: number;
|
||||
}) => {
|
||||
switch (type) {
|
||||
case "domain":
|
||||
return <GlobeIcon size={size} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={size} />;
|
||||
case "host":
|
||||
return <WorkflowIcon size={size} />;
|
||||
default:
|
||||
return <WorkflowIcon size={size} />;
|
||||
}
|
||||
};
|
||||
80
src/modules/control-center/nodes/GroupNode.tsx
Normal file
80
src/modules/control-center/nodes/GroupNode.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
type GroupNodeProps = Node<
|
||||
{
|
||||
group: Group;
|
||||
enabled: boolean;
|
||||
hoverable?: boolean;
|
||||
onClick?: (g: Group) => void;
|
||||
},
|
||||
"groupNode"
|
||||
>;
|
||||
|
||||
export const GroupNode = ({ data, id }: GroupNodeProps) => {
|
||||
const { enabled = true, group, hoverable = true, onClick } = data;
|
||||
|
||||
const countLabel = useMemo(() => {
|
||||
const peerCount = group?.peers_count || 0;
|
||||
const resourceCount = group?.resources_count || 0;
|
||||
if (resourceCount === 0) {
|
||||
return `${peerCount} Peer(s)`;
|
||||
}
|
||||
if (peerCount === 0) {
|
||||
return `${resourceCount} Resource(s)`;
|
||||
}
|
||||
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
|
||||
}, [group?.peers_count, group?.resources_count]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cc-group-node bg-nb-gray-940 border border-nb-gray-800 rounded-lg overflow-hidden transition-all",
|
||||
!enabled && "opacity-60",
|
||||
hoverable && "hover:bg-nb-gray-930 cursor-pointer",
|
||||
)}
|
||||
onClick={() => onClick?.(group)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-3 font-normal text-sm"}>
|
||||
<div
|
||||
className={
|
||||
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
|
||||
}
|
||||
>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={" text-nb-gray-200 font-normal whitespace-nowrap"}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className={"text-nb-gray-400 whitespace-nowrap text-xs"}>
|
||||
{countLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={"sr"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/modules/control-center/nodes/NetworkNode.tsx
Normal file
102
src/modules/control-center/nodes/NetworkNode.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import { NetworkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
|
||||
type NetworkNodeType = {
|
||||
network: Network;
|
||||
};
|
||||
|
||||
type NetworkNodeProps = Node<NetworkNodeType, "networkNode">;
|
||||
|
||||
export const NetworkNode = ({ data }: NetworkNodeProps) => {
|
||||
const { data: networkResources, isLoading: isLoadingResources } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const n = data.network as Network;
|
||||
const resourceIds = n?.resources || [];
|
||||
const routingPeers = n?.routers || [];
|
||||
const resources =
|
||||
networkResources?.filter((r) => resourceIds.includes(r?.id || "")) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-nb-gray-940 border border-nb-gray-800 rounded-2xl overflow-hidden group hover:bg-nb-gray-935 transition-all cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-6 pr-6 py-3.5 font-normal bg-nb-gray-935 border-b border-nb-gray-800 group-hover:bg-nb-gray-930 transition-all",
|
||||
resources?.length === 0 && "border-b-0",
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-3 font-normal text-sm"}>
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
" text-nb-gray-100 font-medium whitespace-nowrap flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<NetworkIcon size={12} />
|
||||
{n?.name}
|
||||
</div>
|
||||
<div className={"text-nb-gray-400 whitespace-nowrap mt-0.5"}>
|
||||
{resources?.length || 0} Resources
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 text-xs"}>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
className={cn(
|
||||
"shrink-0 block",
|
||||
routingPeers?.length === 0 && "bg-nb-gray-500",
|
||||
routingPeers?.length === 1 && "bg-yellow-400",
|
||||
routingPeers?.length > 1 && "bg-green-400",
|
||||
)}
|
||||
/>
|
||||
{routingPeers?.length || 0} Routing Peer(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resources && resources.length > 0 && (
|
||||
<div className={"p-2 flex flex-col gap-4 relative"}>
|
||||
<div className={"grid grid-cols-2 relative z-0"}>
|
||||
{resources?.slice(0, 6).map((r) => {
|
||||
return <DeviceCard resource={r} key={r.id} />;
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-full h-full bg-gradient-to-b from-transparent via-nb-gray-940/20 to-nb-gray-940 z-10 left-0 top-0 pointer-events-none",
|
||||
resources?.length > 6 ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={"sr"}
|
||||
style={{
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
style={{
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/modules/control-center/nodes/PeerNode.tsx
Normal file
44
src/modules/control-center/nodes/PeerNode.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import * as React from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
||||
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||
|
||||
type PeerNodeProps = Node<
|
||||
{
|
||||
peer: Peer;
|
||||
enabled?: boolean;
|
||||
},
|
||||
"peerNode"
|
||||
>;
|
||||
|
||||
export const PeerNode = ({ data, id }: PeerNodeProps) => {
|
||||
const { peer, enabled } = data;
|
||||
const isEnabled = useAnySourceGroupEnabled(id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
|
||||
}
|
||||
>
|
||||
<DeviceCard
|
||||
device={peer}
|
||||
className={cn("p-0", !isEnabled && "opacity-60")}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={"sr"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
src/modules/control-center/nodes/PolicyNode.tsx
Normal file
66
src/modules/control-center/nodes/PolicyNode.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import * as React from "react";
|
||||
import { getPolicyProtocolAndPortText } from "@/modules/control-center/utils/helpers";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type PolicyNode = Node<
|
||||
{
|
||||
policy: Policy;
|
||||
},
|
||||
"policyNode"
|
||||
>;
|
||||
|
||||
export const PolicyNode = ({ data }: PolicyNode) => {
|
||||
const rule = data.policy.rules?.[0];
|
||||
const label = getPolicyProtocolAndPortText(data.policy);
|
||||
const isActive = rule?.enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-nb-gray-940 hover:bg-nb-gray-930 cursor-pointer border border-nb-gray-800 rounded-full flex justify-between overflow-hidden",
|
||||
!isActive && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center justify-center"}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full ml-3 mr-2",
|
||||
isActive ? "bg-green-400" : "bg-nb-gray-400",
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<div className={"pt-2.5 pb-[0.6rem] pr-3 flex gap-4 leading-none"}>
|
||||
<div
|
||||
className={
|
||||
" text-nb-gray-200 font-normal whitespace-nowrap text-[0.8rem] flex items-center justify-center w-full"
|
||||
}
|
||||
>
|
||||
<div className={"truncate max-w-[200px]"}>{rule?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"border-l border-nb-gray-800 flex items-center text-nb-gray-300 text-[0.65rem] pl-2 pr-3 font-mono"
|
||||
}
|
||||
>
|
||||
<div>{label === "" ? "All" : label}</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={"sr"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
src/modules/control-center/nodes/ResourceNode.tsx
Normal file
41
src/modules/control-center/nodes/ResourceNode.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
||||
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
|
||||
|
||||
type ResourceNode = Node<
|
||||
{
|
||||
resource: NetworkResource;
|
||||
enabled?: boolean;
|
||||
},
|
||||
"resourceNode"
|
||||
>;
|
||||
|
||||
export const ResourceNode = ({ data, id }: ResourceNode) => {
|
||||
const { enabled, resource } = data;
|
||||
|
||||
const isEnabled = useAnySourceGroupEnabled(id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"cursor-pointer border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
|
||||
}
|
||||
>
|
||||
<DeviceCard
|
||||
resource={resource}
|
||||
className={cn("p-0", !isEnabled && "opacity-60")}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
style={{
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
135
src/modules/control-center/nodes/SelectGroupNode.tsx
Normal file
135
src/modules/control-center/nodes/SelectGroupNode.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import { sortBy } from "lodash";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
|
||||
type NodeProps = Node<
|
||||
{
|
||||
currentGroup: string;
|
||||
onChange: (id: string) => void;
|
||||
},
|
||||
"selectGroupNode"
|
||||
>;
|
||||
|
||||
export const SelectGroupNode = ({ data, id }: NodeProps) => {
|
||||
const { data: groups, isLoading: isGroupsLoading } =
|
||||
useFetchApi<Group[]>("/groups");
|
||||
|
||||
const groupOptions: SelectOption[] = sortBy(
|
||||
groups?.map(
|
||||
(g) =>
|
||||
({
|
||||
value: g.id,
|
||||
label: g.name,
|
||||
icon: () => (
|
||||
<GroupBadgeIcon id={g?.id} issued={g?.issued} size={14} />
|
||||
),
|
||||
}) as SelectOption,
|
||||
) || [],
|
||||
"label",
|
||||
"asc",
|
||||
);
|
||||
|
||||
const group = groups?.find((g) => g.id === data.currentGroup);
|
||||
|
||||
const countLabel = useMemo(() => {
|
||||
const peerCount = group?.peers_count || 0;
|
||||
const resourceCount = group?.resources_count || 0;
|
||||
if (resourceCount === 0) {
|
||||
return `${peerCount} Peer(s)`;
|
||||
}
|
||||
if (peerCount === 0) {
|
||||
return `${resourceCount} Resource(s)`;
|
||||
}
|
||||
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
|
||||
}, [group]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
|
||||
}
|
||||
>
|
||||
<SelectDropdown
|
||||
variant={"secondary"}
|
||||
value={data.currentGroup}
|
||||
onChange={data.onChange}
|
||||
options={groupOptions}
|
||||
showSearch={true}
|
||||
searchPlaceholder={"Search groups..."}
|
||||
popoverWidth={280}
|
||||
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
|
||||
size={"xs"}
|
||||
maxHeight={300}
|
||||
>
|
||||
<div className={"flex items-center justify-between gap-8 pr-3"}>
|
||||
{group && (
|
||||
<div
|
||||
className={
|
||||
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-3 font-normal text-sm"}>
|
||||
<div
|
||||
className={
|
||||
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
|
||||
}
|
||||
>
|
||||
<GroupBadgeIcon
|
||||
id={group?.id}
|
||||
issued={group?.issued}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
" text-nb-gray-200 font-normal whitespace-nowrap text-left"
|
||||
}
|
||||
>
|
||||
{group.name}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-400 whitespace-nowrap text-xs text-left"
|
||||
}
|
||||
>
|
||||
{countLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</div>
|
||||
</SelectDropdown>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={"sr"}
|
||||
style={{
|
||||
height: 20,
|
||||
width: "1px",
|
||||
border: "none",
|
||||
backgroundColor: "#3f444b",
|
||||
borderRadius: "0px 4px 4px 0px",
|
||||
right: -2,
|
||||
}}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/modules/control-center/nodes/SelectPeerNode.tsx
Normal file
102
src/modules/control-center/nodes/SelectPeerNode.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Handle, type Node, Position } from "@xyflow/react";
|
||||
import { sortBy } from "lodash";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type PeerNodeProps = Node<
|
||||
{
|
||||
currentPeer: string;
|
||||
onPeerChange: (peerId: string) => void;
|
||||
},
|
||||
"selectPeerNode"
|
||||
>;
|
||||
|
||||
export const SelectPeerNode = ({ data, id }: PeerNodeProps) => {
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const peerSelectOptions: SelectOption[] = sortBy(
|
||||
peers?.map(
|
||||
(p) =>
|
||||
({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
icon: () => {
|
||||
const os = p.os as unknown as OperatingSystem;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
os === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
os === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
os === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={p.os} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}) as SelectOption,
|
||||
) || [],
|
||||
"label",
|
||||
"asc",
|
||||
);
|
||||
|
||||
const peer = peers?.find((p) => p.id === data.currentPeer);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-930 border hover:bg-nb-gray-910 cursor-pointer border-nb-gray-800 rounded-lg overflow-hidden transition-all"
|
||||
}
|
||||
>
|
||||
<SelectDropdown
|
||||
variant={"secondary"}
|
||||
value={data.currentPeer}
|
||||
onChange={data.onPeerChange}
|
||||
options={peerSelectOptions}
|
||||
showSearch={true}
|
||||
searchPlaceholder={"Search peers..."}
|
||||
popoverWidth={280}
|
||||
className={"!bg-nb-gray-920 !hover:bg-nb-gray-925 !text-nb-gray-300"}
|
||||
size={"xs"}
|
||||
maxHeight={300}
|
||||
>
|
||||
<div className={"flex items-center justify-between gap-8 pr-3"}>
|
||||
{peer && <DeviceCard device={peer} />}
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</div>
|
||||
</SelectDropdown>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={"sr"}
|
||||
style={{
|
||||
height: 20,
|
||||
width: "1px",
|
||||
border: "none",
|
||||
backgroundColor: "#3f444b",
|
||||
borderRadius: "0px 4px 4px 0px",
|
||||
right: -2,
|
||||
}}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={"tl"}
|
||||
className={"opacity-0"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
src/modules/control-center/utils/edge-helper.ts
Normal file
90
src/modules/control-center/utils/edge-helper.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { InternalNode, Node, Position } from "@xyflow/react";
|
||||
|
||||
type IntersectionPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
function getNodeIntersection(
|
||||
intersectionNode: InternalNode<Node>,
|
||||
targetNode: InternalNode<Node>,
|
||||
) {
|
||||
const { width: intersectionNodeWidth, height: intersectionNodeHeight } =
|
||||
intersectionNode.measured;
|
||||
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
|
||||
const targetPosition = targetNode.internals.positionAbsolute;
|
||||
const measuredTargetWidth = targetNode.measured.width || 0;
|
||||
const measuredTargetHeight = targetNode.measured.height || 0;
|
||||
|
||||
const w = (intersectionNodeWidth || 0) / 2;
|
||||
const h = (intersectionNodeHeight || 0) / 2;
|
||||
|
||||
const x2 = intersectionNodePosition.x + w;
|
||||
const y2 = intersectionNodePosition.y + h;
|
||||
const x1 = targetPosition.x + measuredTargetWidth / 2;
|
||||
const y1 = targetPosition.y + measuredTargetHeight / 2;
|
||||
|
||||
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
|
||||
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
|
||||
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
|
||||
const xx3 = a * xx1;
|
||||
const yy3 = a * yy1;
|
||||
const x = w * (xx3 + yy3) + x2;
|
||||
const y = h * (-xx3 + yy3) + y2;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function getEdgePosition(
|
||||
node: InternalNode<Node>,
|
||||
intersectionPoint: IntersectionPoint,
|
||||
) {
|
||||
const n = { ...node.internals.positionAbsolute, ...node };
|
||||
const nx = Math.round(n.x);
|
||||
const ny = Math.round(n.y);
|
||||
const px = Math.round(intersectionPoint.x);
|
||||
const py = Math.round(intersectionPoint.y);
|
||||
const measuredWidth = n.measured.width || 0;
|
||||
const measuredHeight = n.measured.height || 0;
|
||||
|
||||
if (px <= nx + 1) {
|
||||
return Position.Left;
|
||||
}
|
||||
if (px >= nx + measuredWidth - 1) {
|
||||
return Position.Right;
|
||||
}
|
||||
if (py <= ny + 1) {
|
||||
return Position.Top;
|
||||
}
|
||||
if (py >= n.y + measuredHeight - 1) {
|
||||
return Position.Bottom;
|
||||
}
|
||||
|
||||
return Position.Top;
|
||||
}
|
||||
|
||||
export function getEdgeParams(
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>,
|
||||
) {
|
||||
const sourceIntersectionPoint: IntersectionPoint = getNodeIntersection(
|
||||
source,
|
||||
target,
|
||||
);
|
||||
const targetIntersectionPoint: IntersectionPoint = getNodeIntersection(
|
||||
target,
|
||||
source,
|
||||
);
|
||||
|
||||
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
|
||||
const targetPos = getEdgePosition(target, targetIntersectionPoint);
|
||||
|
||||
return {
|
||||
sx: sourceIntersectionPoint.x,
|
||||
sy: sourceIntersectionPoint.y,
|
||||
tx: targetIntersectionPoint.x,
|
||||
ty: targetIntersectionPoint.y,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
};
|
||||
}
|
||||
13
src/modules/control-center/utils/edges.ts
Normal file
13
src/modules/control-center/utils/edges.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import AnimatedLine from "@/modules/control-center/edges/AnimatedLine";
|
||||
import { BidirectionalEdges } from "@/modules/control-center/edges/BidirectionalEdges";
|
||||
import { DirectionIn } from "@/modules/control-center/edges/DirectionIn";
|
||||
import FloatingEdge from "@/modules/control-center/edges/FloatingEdge";
|
||||
import { SimpleConnection } from "@/modules/control-center/edges/SimpleConnection";
|
||||
|
||||
export const EDGE_TYPES = {
|
||||
in: DirectionIn,
|
||||
bi: BidirectionalEdges,
|
||||
floating: FloatingEdge,
|
||||
"floating-straight": AnimatedLine,
|
||||
simple: SimpleConnection,
|
||||
};
|
||||
145
src/modules/control-center/utils/helpers.ts
Normal file
145
src/modules/control-center/utils/helpers.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { orderBy } from "lodash";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
export const getDestinationGroupsFromPolicy = (policy: Policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return [];
|
||||
const destinations = rule.destinations as Group[];
|
||||
if (!destinations) return [];
|
||||
return destinations;
|
||||
};
|
||||
|
||||
export const getSourceGroupsFromPolicy = (policy: Policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return [];
|
||||
const sources = rule.sources as Group[];
|
||||
if (!sources) return [];
|
||||
return sources;
|
||||
};
|
||||
|
||||
export const getNetworksFromPolicy = (networks: Network[], policy: Policy) => {
|
||||
const policyId = policy.id;
|
||||
if (!policyId) return [];
|
||||
return networks.filter((network) => {
|
||||
return network.policies?.some((p) => p === policyId);
|
||||
});
|
||||
};
|
||||
|
||||
export const getPeersFromGroup = (group: Group, peers: Peer[]) => {
|
||||
return peers.filter((peer) => {
|
||||
const groupIds = peer.groups?.map((g) => g.id) || [];
|
||||
return groupIds.includes(group.id);
|
||||
});
|
||||
};
|
||||
|
||||
export const getPolicyProtocolAndPortText = (
|
||||
policy: Policy,
|
||||
maxPorts?: number,
|
||||
) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return "";
|
||||
let p = rule.protocol;
|
||||
|
||||
if (p === "all") {
|
||||
return "";
|
||||
} else if (p === "icmp") {
|
||||
return "ICMP";
|
||||
} else {
|
||||
const ports = getPolicyPortsText(policy);
|
||||
if (!ports || ports.length === 0) {
|
||||
return p.toUpperCase();
|
||||
}
|
||||
if (ports.length > (maxPorts ?? 3)) {
|
||||
const firstFour = ports.slice(0, 4);
|
||||
return `${p.toUpperCase()}:${firstFour.join(",")}, ...`;
|
||||
}
|
||||
return `${p.toUpperCase()}:${ports.join(",")}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPolicyPortsText = (policy: Policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return undefined;
|
||||
|
||||
const ports = rule.ports || [];
|
||||
const portRanges = rule.port_ranges || [];
|
||||
|
||||
if (ports.length === 0 && portRanges.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const portStrings = ports.map((port) => String(port));
|
||||
const rangeStrings = portRanges.map((range) => {
|
||||
if (range.start === range.end) return String(range.start);
|
||||
return `${range.start}-${range.end}`;
|
||||
});
|
||||
|
||||
return orderBy(
|
||||
[...portStrings, ...rangeStrings],
|
||||
[(x) => Number(x.split("-")[0])],
|
||||
["asc"],
|
||||
);
|
||||
};
|
||||
|
||||
export const getResourcePolicyByGroups = (
|
||||
groups: Group[],
|
||||
policies: Policy[],
|
||||
): Policy[] => {
|
||||
const groupIds = groups.map((group) => group.id);
|
||||
return policies.filter((policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return false;
|
||||
const destinations = rule.destinations as Group[];
|
||||
return destinations?.some((d) => groupIds.includes(d.id));
|
||||
});
|
||||
};
|
||||
|
||||
export function useSourceGroupEnabled(sourceId: string) {
|
||||
const { getNode } = useReactFlow();
|
||||
const node = getNode(sourceId);
|
||||
return node?.data?.enabled ?? false;
|
||||
}
|
||||
|
||||
export function useAnySourceGroupEnabled(sourceId: string) {
|
||||
const { getNodes, getEdges } = useReactFlow();
|
||||
|
||||
const nodes = getNodes();
|
||||
const edges = getEdges();
|
||||
|
||||
const incomingEdges = edges.filter((e) => e.target === sourceId);
|
||||
const sourceNodes = incomingEdges
|
||||
.map((edge) => nodes.find((n) => n.id === edge.source))
|
||||
.filter(Boolean);
|
||||
const sourceEnabledStates = sourceNodes.map((n) => n?.data?.enabled);
|
||||
return sourceEnabledStates.some(Boolean);
|
||||
}
|
||||
|
||||
export function getFirstGroup(groups?: Group[], policies?: Policy[]) {
|
||||
const sortedGroups = orderBy(groups, "peers_count", "desc");
|
||||
const groupsWithoutAll = sortedGroups?.filter((g) => g.name !== "All");
|
||||
|
||||
const groupsWithPolicies = orderBy(
|
||||
groupsWithoutAll?.filter((g) => {
|
||||
return policies?.some((p) => {
|
||||
const sources = getSourceGroupsFromPolicy(p);
|
||||
return sources?.some((source) => source.id === g.id);
|
||||
});
|
||||
}),
|
||||
"peers_count",
|
||||
"desc",
|
||||
);
|
||||
|
||||
if (groupsWithPolicies && groupsWithPolicies?.length > 0) {
|
||||
return groupsWithPolicies[0];
|
||||
}
|
||||
|
||||
if (groupsWithoutAll && groupsWithoutAll?.length > 0) {
|
||||
return groupsWithoutAll[0];
|
||||
}
|
||||
|
||||
return sortedGroups?.[0];
|
||||
}
|
||||
245
src/modules/control-center/utils/layouts.ts
Normal file
245
src/modules/control-center/utils/layouts.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Edge, Node } from "@xyflow/react";
|
||||
import * as d3 from "d3";
|
||||
|
||||
interface SimulationNode extends Node {
|
||||
x: number;
|
||||
y: number;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_ZOOM = 0.8;
|
||||
export const DEFAULT_MIN_ZOOM = 0.2;
|
||||
|
||||
export const applyD3ForceLayout = (nodes: Node[], edges: Edge[]) => {
|
||||
const simulationNodes: SimulationNode[] = nodes.map((node) => ({
|
||||
...node,
|
||||
x: node.position?.x || 0,
|
||||
y: node.position?.y || 0,
|
||||
}));
|
||||
|
||||
const simulationLinks = edges.map((edge) => ({
|
||||
...edge,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}));
|
||||
|
||||
// Apply minimal D3 simulation for final positioning with reduced link distance
|
||||
const simulation = d3
|
||||
.forceSimulation(simulationNodes)
|
||||
.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink(simulationLinks)
|
||||
.id((d: any) => d.id)
|
||||
.distance(60) // Reduced distance to minimize crossings
|
||||
.strength(0.05), // Reduced strength to maintain radial structure
|
||||
)
|
||||
.force("collision", d3.forceCollide().radius(300));
|
||||
|
||||
// Run simulation for fewer iterations to preserve radial structure
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
simulation.tick();
|
||||
}
|
||||
|
||||
const updatedNodes: Node[] = simulationNodes.map((node) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
},
|
||||
}));
|
||||
|
||||
const updatedEdges: Edge[] = edges.map((edge) => {
|
||||
const sourceNode = simulationNodes.find((n) => n.id === edge.source);
|
||||
const targetNode = simulationNodes.find((n) => n.id === edge.target);
|
||||
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
points:
|
||||
sourceNode && targetNode
|
||||
? [
|
||||
{ x: sourceNode.x, y: sourceNode.y },
|
||||
{ x: targetNode.x, y: targetNode.y },
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
simulation.stop();
|
||||
|
||||
return { updatedNodes, updatedEdges };
|
||||
};
|
||||
|
||||
export const applyD3HierarchicalLayout = (
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
width = 280,
|
||||
spacing = 100,
|
||||
view?: string,
|
||||
options?: {
|
||||
policy?: { width: number; spacing: number };
|
||||
destinationGroup?: { width: number; spacing: number };
|
||||
peersAndResources?: { width: number; spacing: number };
|
||||
},
|
||||
) => {
|
||||
const simulationNodes: SimulationNode[] = nodes.map((node) => ({
|
||||
...node,
|
||||
x: node.position?.x || 0,
|
||||
y: node.position?.y || 0,
|
||||
}));
|
||||
|
||||
const columnWidth = width;
|
||||
const nodeSpacing = spacing;
|
||||
const startX = 0;
|
||||
const centerY = 0;
|
||||
|
||||
const groupNodes = simulationNodes.filter((n) => n.type === "groupNode");
|
||||
const sourceGroupNodes = simulationNodes.filter(
|
||||
(n) => n.type === "sourceGroupNode",
|
||||
);
|
||||
const destinationGroupNodes = simulationNodes.filter(
|
||||
(n) => n.type === "destinationGroupNode",
|
||||
);
|
||||
const policyNodes = simulationNodes.filter((n) => n.type === "policyNode");
|
||||
const networkNodes = simulationNodes.filter((n) => n.type === "networkNode");
|
||||
const resourceNodes = simulationNodes.filter(
|
||||
(n) => n.type === "resourceNode",
|
||||
);
|
||||
const peerNodes = simulationNodes.filter((n) => n.type === "peerNode");
|
||||
const expandedGroupPeers = simulationNodes.filter(
|
||||
(n) => n.type === "expandedGroupPeer",
|
||||
);
|
||||
|
||||
let networkAndResourceNodes = [...networkNodes, ...resourceNodes];
|
||||
|
||||
if (view === "group") {
|
||||
networkAndResourceNodes = [...networkAndResourceNodes, ...peerNodes];
|
||||
}
|
||||
|
||||
if (view === "peer") {
|
||||
networkAndResourceNodes = [
|
||||
...networkAndResourceNodes,
|
||||
...expandedGroupPeers,
|
||||
];
|
||||
}
|
||||
|
||||
// Peers
|
||||
if (peerNodes.length > 0 && view !== "group") {
|
||||
centerNodesVertically(
|
||||
peerNodes,
|
||||
startX + (view === "group" ? columnWidth * 4 : 0),
|
||||
nodeSpacing,
|
||||
centerY,
|
||||
);
|
||||
}
|
||||
|
||||
// Groups or Source Groups
|
||||
centerNodesVertically(groupNodes, startX, nodeSpacing, centerY);
|
||||
centerNodesVertically(
|
||||
sourceGroupNodes,
|
||||
startX + columnWidth,
|
||||
nodeSpacing,
|
||||
centerY,
|
||||
);
|
||||
|
||||
// Policies
|
||||
centerNodesVertically(
|
||||
policyNodes,
|
||||
startX + (options?.policy?.width ?? columnWidth),
|
||||
options?.policy?.spacing ?? nodeSpacing,
|
||||
centerY + 14,
|
||||
);
|
||||
|
||||
// Destination Groups
|
||||
centerNodesVertically(
|
||||
destinationGroupNodes,
|
||||
startX + (options?.destinationGroup?.width ?? columnWidth),
|
||||
options?.destinationGroup?.spacing ?? nodeSpacing,
|
||||
centerY,
|
||||
);
|
||||
|
||||
// Networks
|
||||
centerNodesVertically(
|
||||
networkAndResourceNodes,
|
||||
startX + (options?.peersAndResources?.width ?? columnWidth),
|
||||
options?.peersAndResources?.spacing ?? nodeSpacing,
|
||||
centerY + 5,
|
||||
);
|
||||
|
||||
const simulation = d3
|
||||
.forceSimulation(simulationNodes)
|
||||
.force("charge", d3.forceManyBody().strength(0))
|
||||
.force("collision", d3.forceCollide().radius(0))
|
||||
.alphaDecay(0.05)
|
||||
.velocityDecay(0.7);
|
||||
|
||||
simulation.force("position", (alpha) => {
|
||||
simulationNodes.forEach((node) => {
|
||||
let targetX = node.x;
|
||||
let targetY = node.y;
|
||||
|
||||
const dx = targetX - node.x;
|
||||
const dy = targetY - node.y;
|
||||
|
||||
node.vx = (node.vx || 0) + dx * alpha * 0.1;
|
||||
node.vy = (node.vy || 0) + dy * alpha * 0.1;
|
||||
});
|
||||
});
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
simulation.tick();
|
||||
}
|
||||
|
||||
const updatedNodes: Node[] = simulationNodes.map((node) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
},
|
||||
}));
|
||||
|
||||
const updatedEdges: Edge[] = edges.map((edge) => {
|
||||
const sourceNode = simulationNodes.find((n) => n.id === edge.source);
|
||||
const targetNode = simulationNodes.find((n) => n.id === edge.target);
|
||||
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
points:
|
||||
sourceNode && targetNode
|
||||
? [
|
||||
{ x: sourceNode.x, y: sourceNode.y },
|
||||
{ x: targetNode.x, y: targetNode.y },
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
simulation.stop();
|
||||
|
||||
return { updatedNodes, updatedEdges };
|
||||
};
|
||||
|
||||
const centerNodesVertically = (
|
||||
nodesList: SimulationNode[],
|
||||
x: number,
|
||||
nodeSpacing: number,
|
||||
centerY: number,
|
||||
enable = true,
|
||||
) => {
|
||||
if (nodesList.length === 0) return;
|
||||
|
||||
const totalHeight = (nodesList.length - 1) * nodeSpacing;
|
||||
const startY = centerY - totalHeight / 2;
|
||||
|
||||
nodesList.forEach((node, index) => {
|
||||
node.x = x;
|
||||
node.y = (enable ? startY : 0) + index * nodeSpacing;
|
||||
});
|
||||
};
|
||||
20
src/modules/control-center/utils/nodes.ts
Normal file
20
src/modules/control-center/utils/nodes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { GroupNode } from "@/modules/control-center/nodes/GroupNode";
|
||||
import { NetworkNode } from "@/modules/control-center/nodes/NetworkNode";
|
||||
import { PeerNode } from "@/modules/control-center/nodes/PeerNode";
|
||||
import { PolicyNode } from "@/modules/control-center/nodes/PolicyNode";
|
||||
import { ResourceNode } from "@/modules/control-center/nodes/ResourceNode";
|
||||
import { SelectGroupNode } from "@/modules/control-center/nodes/SelectGroupNode";
|
||||
import { SelectPeerNode } from "@/modules/control-center/nodes/SelectPeerNode";
|
||||
|
||||
export const NODE_TYPES = {
|
||||
groupNode: GroupNode,
|
||||
sourceGroupNode: GroupNode,
|
||||
destinationGroupNode: GroupNode,
|
||||
networkNode: NetworkNode,
|
||||
resourceNode: ResourceNode,
|
||||
policyNode: PolicyNode,
|
||||
peerNode: PeerNode,
|
||||
expandedGroupPeer: PeerNode,
|
||||
selectPeerNode: SelectPeerNode,
|
||||
selectGroupNode: SelectGroupNode,
|
||||
};
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
|
||||
export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
const exitNodeInfo = useHasExitNodes(peer);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
@@ -25,7 +25,7 @@ export const ExitNodeDropdownButton = ({ peer }: Props) => {
|
||||
disabled={!permission.routes.create}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
{hasExitNodes ? (
|
||||
{exitNodeInfo.hasExitNode ? (
|
||||
<>
|
||||
<IconCirclePlus size={14} className={"shrink-0"} />
|
||||
<div className={"flex justify-between items-center w-full"}>
|
||||
|
||||
@@ -8,18 +8,22 @@ type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const ExitNodePeerIndicator = ({ peer }: Props) => {
|
||||
const hasExitNode = useHasExitNodes(peer);
|
||||
const exitNodeInfo = useHasExitNodes(peer);
|
||||
|
||||
return hasExitNode ? (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
This peer is an exit node. Traffic from the configured distribution
|
||||
groups will be routed through this peer.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<IconDirectionSign size={15} className={"text-yellow-400 shrink-0"} />
|
||||
if (!exitNodeInfo.hasExitNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = exitNodeInfo.skipAutoApply === false
|
||||
? "This peer is an auto-applied exit node. Traffic from the configured distribution groups will be routed through this peer."
|
||||
: "This peer is an exit node. Traffic from the configured distribution groups will be routed through this peer.";
|
||||
|
||||
return (
|
||||
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
|
||||
<IconDirectionSign
|
||||
size={15}
|
||||
className={`shrink-0 ${exitNodeInfo.skipAutoApply === false ? "text-green-400" : "text-yellow-400"}`}
|
||||
/>
|
||||
</FullTooltip>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
|
||||
export const useHasExitNodes = (peer?: Peer) => {
|
||||
export interface ExitNodeInfo {
|
||||
hasExitNode: boolean;
|
||||
skipAutoApply?: boolean;
|
||||
}
|
||||
|
||||
export const useHasExitNodes = (peer?: Peer): ExitNodeInfo => {
|
||||
const { isOwnerOrAdmin } = useLoggedInUser();
|
||||
const { data: routes } = useFetchApi<Route[]>(
|
||||
`/routes`,
|
||||
@@ -11,9 +16,17 @@ export const useHasExitNodes = (peer?: Peer) => {
|
||||
true,
|
||||
isOwnerOrAdmin,
|
||||
);
|
||||
return peer
|
||||
? routes?.some(
|
||||
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
|
||||
) || false
|
||||
: false;
|
||||
|
||||
if (!peer || !routes) {
|
||||
return { hasExitNode: false };
|
||||
}
|
||||
|
||||
const exitNodeRoute = routes.find(
|
||||
(route) => route?.peer === peer.id && route?.network === "0.0.0.0/0",
|
||||
);
|
||||
|
||||
return {
|
||||
hasExitNode: !!exitNodeRoute,
|
||||
skipAutoApply: exitNodeRoute?.skip_auto_apply,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ const NetworksContext = React.createContext(
|
||||
resource?: NetworkResource,
|
||||
) => void;
|
||||
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
|
||||
deleteNetwork: (network: Network) => void;
|
||||
deleteNetwork: (network: Network) => Promise<void>;
|
||||
deleteResource: (network: Network, resource: NetworkResource) => void;
|
||||
deleteRouter: (network: Network, router: NetworkRouter) => void;
|
||||
network?: Network;
|
||||
@@ -124,15 +124,19 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
|
||||
if (!choice) return;
|
||||
|
||||
const promise = deleteCall({}, `/${network.id}`).then(() => {
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
});
|
||||
|
||||
notify({
|
||||
title: network.name,
|
||||
description: "Network deleted successfully.",
|
||||
loadingMessage: "Deleting network...",
|
||||
promise: deleteCall({}, `/${network.id}`).then(() => {
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
}),
|
||||
promise,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const deleteResource = async (
|
||||
@@ -250,8 +254,9 @@ export const NetworkProvider = ({ children, network }: Props) => {
|
||||
mutate("/networks");
|
||||
await askForResource(network);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
onUpdated={(n) => {
|
||||
mutate("/networks");
|
||||
mutate(`/networks/${n.id}`);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
|
||||
@@ -20,9 +20,9 @@ export const NetworkInformationSquare = ({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center max-w-[300px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
|
||||
"flex w-full items-center max-w-[450px] gap-4 dark:text-neutral-300 text-neutral-500 transition-all group/network rounded-md",
|
||||
onClick
|
||||
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-5 relative"
|
||||
? "hover:text-neutral-100 hover:bg-nb-gray-910 cursor-pointer py-2 pl-3 pr-14 relative"
|
||||
: "cursor-default",
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -50,10 +50,10 @@ export const NetworkInformationSquare = ({
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<div className={"mt-[0px] flex items-center flex-wrap"}>
|
||||
<div className={"mt-[0px] flex items-start flex-wrap flex-col"}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium",
|
||||
"font-medium text-left whitespace-nowrap",
|
||||
size == "md" ? "text-sm" : "text-xl leading-none mb-0.5",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function NetworkNameCell({ network }: Readonly<Props>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-4 items-center min-w-[300px] max-w-[300px]"}>
|
||||
<div className={"flex gap-4 items-center min-w-[300px] max-w-[450px]"}>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
|
||||
21
src/modules/peer/EphemeralPeerIndicator.tsx
Normal file
21
src/modules/peer/EphemeralPeerIndicator.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {PowerOffIcon} from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const EphemeralPeerIndicator = ({ peer }: Props) => {
|
||||
if (!peer.ephemeral) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = "This peer is an ephemeral peer. If it is disconnected for more than 10 minutes it will be removed.";
|
||||
|
||||
return (
|
||||
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
|
||||
<PowerOffIcon size={12} className={"shrink-0 text-yellow-400"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
23
src/modules/peer/ExpirationDisabledIndicator.tsx
Normal file
23
src/modules/peer/ExpirationDisabledIndicator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { TimerResetIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const ExpirationDisabledIndicator = ({ peer }: Props) => {
|
||||
if (peer.login_expiration_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = "Expiration is disabled for this peer.";
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}
|
||||
>
|
||||
<TimerResetIcon size={14} className={"shrink-0 text-nb-gray-300"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
27
src/modules/peer/LoginRequiredIndicator.tsx
Normal file
27
src/modules/peer/LoginRequiredIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const LoginRequiredIndicator = ({ peer }: Props) => {
|
||||
if (!peer.login_expired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{" "}
|
||||
This peer is offline and needs to be <br />
|
||||
re-authenticated because its login has expired.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={14} className={"shrink-0 text-red-500"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
|
||||
export const PeerNetworkRoutesSection = ({ peer }: Props) => {
|
||||
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
|
||||
const hasExitNodes = useHasExitNodes(peer);
|
||||
const exitNodeInfo = useHasExitNodes(peer);
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PeerNetworkRoutesSection = ({ peer }: Props) => {
|
||||
</div>
|
||||
<div className={"inline-flex gap-4 justify-end"}>
|
||||
<div className={"gap-4 flex"}>
|
||||
<AddExitNodeButton peer={peer} firstTime={!hasExitNodes} />
|
||||
<AddExitNodeButton peer={peer} firstTime={!exitNodeInfo.hasExitNode} />
|
||||
<AddRouteDropdownButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
111
src/modules/peer/PeerSSHInstructions.tsx
Normal file
111
src/modules/peer/PeerSSHInstructions.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import Separator from "@components/Separator";
|
||||
import Steps from "@components/Steps";
|
||||
import { Lightbox } from "@components/ui/Lightbox";
|
||||
import { Mark } from "@components/ui/Mark";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, TerminalSquare } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import sshImage from "@/assets/ssh/ssh-client.png";
|
||||
|
||||
type Props = {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const PeerSSHInstructions = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
maxWidthClass={cn("relative", "max-w-2xl")}
|
||||
showClose={true}
|
||||
>
|
||||
<ModalHeader
|
||||
icon={<TerminalSquare size={16} className={"text-netbird"} />}
|
||||
title={"Enable SSH Access"}
|
||||
description={
|
||||
"Allow remote SSH access to this machine from other connected network participants. NetBird's embedded SSH server is running on port 44338."
|
||||
}
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-3 flex flex-col gap-0 z-0"}>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via CLI, you can enable SSH by running
|
||||
</p>
|
||||
<Code codeToCopy={"netbird down"}>
|
||||
<Code.Line>{`netbird down # if NetBird is already running`}</Code.Line>
|
||||
</Code>
|
||||
<Code>
|
||||
<Code.Line>{`netbird up --allow-server-ssh`}</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via the Desktop Client, click on the
|
||||
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "}
|
||||
<Mark>Allow SSH</Mark> <br />
|
||||
</p>
|
||||
<Lightbox image={sshImage} />
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Once the NetBird SSH server is allowed on the client, <br />
|
||||
click <Mark>Confirm & Enable</Mark> below to finish the setup.
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/ssh"}
|
||||
target={"_blank"}
|
||||
>
|
||||
SSH
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={onSuccess}
|
||||
data-cy={"create-setup-key"}
|
||||
>
|
||||
Confirm & Enable
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
47
src/modules/peer/PeerSSHToggle.tsx
Normal file
47
src/modules/peer/PeerSSHToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { LockIcon, TerminalSquare } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
export const PeerSSHToggle = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={permission.peers.update}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={peer.ssh_enabled}
|
||||
disabled={!permission.peers.update}
|
||||
onChange={(enable) =>
|
||||
enable ? setSSHInstructionsModal(true) : toggleSSH(false)
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<TerminalSquare size={16} />
|
||||
SSH Access
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable the SSH server on this peer to access the machine via an secure shell."
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
|
||||
|
||||
export default function PeerActionCell() {
|
||||
const { peer, deletePeer, update, openSSHDialog } = usePeer();
|
||||
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
|
||||
usePeer();
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
@@ -48,23 +49,8 @@ export default function PeerActionCell() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSSH = async () => {
|
||||
const text = peer.ssh_enabled ? "disabled" : "enabled";
|
||||
notify({
|
||||
title: `SSH Server is ${text}`,
|
||||
description: `The SSH Server for the peer ${peer.name} was successfully ${text}.`,
|
||||
promise: update({
|
||||
ssh: !peer.ssh_enabled,
|
||||
}).then(() => {
|
||||
mutate("/peers");
|
||||
mutate("/groups");
|
||||
}),
|
||||
loadingMessage: "Updating SSH access...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<div className={"flex justify-end pr-4 gap-3"}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
@@ -118,10 +104,8 @@ export default function PeerActionCell() {
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
peer.ssh_enabled
|
||||
? toggleSSH()
|
||||
: openSSHDialog().then((enable) =>
|
||||
enable ? toggleSSH() : null,
|
||||
)
|
||||
? toggleSSH(false)
|
||||
: setSSHInstructionsModal(true)
|
||||
}
|
||||
disabled={!permission.peers.update}
|
||||
>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex gap-4 items-center min-w-[320px] max-w-[320px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
|
||||
"flex gap-2.5 items-center min-w-[300px] max-w-[300px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -32,13 +32,13 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full h-8 w-8 shrink-0 bg-nb-gray-920/80 transition-all",
|
||||
"flex items-center justify-center rounded-full h-3 w-3 shrink-0 relative -top-[0.5rem]",
|
||||
)}
|
||||
>
|
||||
{isEmpty(peer.country_code) ? (
|
||||
<GlobeIcon size={16} className={"text-nb-gray-300"} />
|
||||
) : (
|
||||
<RoundedFlag country={peer.country_code} size={20} />
|
||||
<RoundedFlag country={peer.country_code} size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ListItem } from "@components/ListItem";
|
||||
import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -104,30 +104,3 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
80
src/modules/peers/PeerConnectButton.tsx
Normal file
80
src/modules/peers/PeerConnectButton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
|
||||
export const PeerConnectButton = () => {
|
||||
const { peer } = usePeer();
|
||||
const isConnected = peer.connected;
|
||||
const os = getOperatingSystem(peer?.os);
|
||||
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
|
||||
|
||||
if (isMobile) return;
|
||||
|
||||
return isConnected ? (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={"group"}>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto"
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
sideOffset={8}
|
||||
>
|
||||
<SSHButton peer={peer} isDropdown={true} />
|
||||
<RDPButton peer={peer} isDropdown={true} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-[200px] text-xs"}>
|
||||
Connecting via SSH or RDP is only available when the peer is online.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConnectButton disabled={true} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex gap-2 items-center text-sm text-nb-gray-300 hover:text-white disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-nb-gray-800/60 rounded-md py-2 px-3 disabled:text-nb-gray-700",
|
||||
// group data state open
|
||||
"group-data-[state=open]:bg-nb-gray-800/30",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
<IconChevronDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user