Compare commits
26 Commits
ui-refacto
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842ef0d657 | ||
|
|
439f44c6b4 | ||
|
|
b5a970155b | ||
|
|
686e0d97f2 | ||
|
|
f7f5946910 | ||
|
|
1aae067aaa | ||
|
|
0c2702c0d7 | ||
|
|
18e3b5dd32 | ||
|
|
f3f9704c6f | ||
|
|
4c3d4effbd | ||
|
|
3953fee5a4 | ||
|
|
adeaa49cda | ||
|
|
2c5d52a1bf | ||
|
|
70a755fbae | ||
|
|
559da5d5b9 | ||
|
|
614ee11ac7 | ||
|
|
85080afa59 | ||
|
|
062a183e4e | ||
|
|
a2be41caf8 | ||
|
|
debb558aa3 | ||
|
|
553be144b4 | ||
|
|
c3f9514182 | ||
|
|
bfe19fa542 | ||
|
|
d07f25fc49 | ||
|
|
670b0f66ac | ||
|
|
15d73a2edd |
2
client/ui/.gitignore
vendored
@@ -2,5 +2,7 @@
|
|||||||
bin
|
bin
|
||||||
frontend/dist
|
frontend/dist
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
|
frontend/bindings
|
||||||
|
frontend/.vite
|
||||||
build/linux/appimage/build
|
build/linux/appimage/build
|
||||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!--
|
<!--
|
||||||
macOS Icon Composer source. icon.json references this SVG by name and
|
macOS Icon Composer source. Designed on a 1024x1024 canvas with the bird
|
||||||
applies its own scale/translation/fill, so we leave the artwork in its
|
glyph centered and sized to ~75% of canvas width, leaving padding for
|
||||||
native 31×23 viewBox.
|
the system squircle treatment.
|
||||||
-->
|
-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 31 23">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
<g transform="translate(128, 227) scale(24.77)">
|
||||||
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 732 B After Width: | Height: | Size: 810 B |
@@ -1,45 +1,20 @@
|
|||||||
{
|
{
|
||||||
"fill" : {
|
"fill" : {
|
||||||
"automatic-gradient" : "extended-gray:1.00000,1.00000"
|
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||||
},
|
},
|
||||||
"groups" : [
|
"groups" : [
|
||||||
{
|
{
|
||||||
"layers" : [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"fill-specializations" : [
|
|
||||||
{
|
|
||||||
"appearance" : "dark",
|
|
||||||
"value" : {
|
|
||||||
"solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearance" : "tinted",
|
|
||||||
"value" : {
|
|
||||||
"solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"image-name" : "wails_icon_vector.svg",
|
"image-name" : "wails_icon_vector.svg",
|
||||||
"name" : "wails_icon_vector",
|
"name" : "wails_icon_vector"
|
||||||
"position" : {
|
|
||||||
"scale" : 1.25,
|
|
||||||
"translation-in-points" : [
|
|
||||||
36.890625,
|
|
||||||
4.96875
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shadow" : {
|
"shadow" : {
|
||||||
"kind" : "neutral",
|
"kind" : "neutral",
|
||||||
"opacity" : 0.5
|
"opacity" : 0.5
|
||||||
},
|
},
|
||||||
"specular" : true,
|
"specular" : true
|
||||||
"translucency" : {
|
|
||||||
"enabled" : true,
|
|
||||||
"value" : 0.5
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"supported-platforms" : {
|
"supported-platforms" : {
|
||||||
|
|||||||
BIN
client/ui/build/darwin/Assets.car
Normal file
@@ -5,6 +5,8 @@
|
|||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>NetBird</string>
|
<string>NetBird</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NetBird</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>netbird-ui</string>
|
<string>netbird-ui</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>NetBird</string>
|
<string>NetBird</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NetBird</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>netbird-ui</string>
|
<string>netbird-ui</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ tasks:
|
|||||||
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
|
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
|
||||||
|
|
||||||
run:
|
run:
|
||||||
|
deps:
|
||||||
|
- task: common:generate:icons
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
|||||||
7
client/ui/frontend/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dist
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
pnpm-lock.yaml
|
||||||
|
wailsjs
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
10
client/ui/frontend/.prettierrc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -26,6 +26,15 @@ export function GetLogLevel(): $CancellablePromise<$models.LogLevel> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RevealFile opens the OS file manager focused on the given path. Wails'
|
||||||
|
* Browser.OpenURL refuses non-http(s) schemes, so the UI calls this binding
|
||||||
|
* instead of constructing a file:// URL.
|
||||||
|
*/
|
||||||
|
export function RevealFile(path: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2620662837, path);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetLogLevel(lvl: $models.LogLevel): $CancellablePromise<void> {
|
export function SetLogLevel(lvl: $models.LogLevel): $CancellablePromise<void> {
|
||||||
return $Call.ByID(4122411498, lvl);
|
return $Call.ByID(4122411498, lvl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/app.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,23 +8,43 @@
|
|||||||
"build:dev": "tsc && vite build --minify false --mode development",
|
"build:dev": "tsc && vite build --minify false --mode development",
|
||||||
"build": "tsc && vite build --mode production",
|
"build": "tsc && vite build --mode production",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@wailsio/runtime": "latest",
|
"@wailsio/runtime": "latest",
|
||||||
|
"chroma-js": "^3.2.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"react-router-dom": "^7.1.3",
|
"react-router-dom": "^7.1.3",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/chroma-js": "^3.1.2",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
2900
client/ui/frontend/pnpm-lock.yaml
generated
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,157 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
|
||||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
|
||||||
sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 24px;
|
|
||||||
font-weight: 400;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: rgba(27, 38, 54, 1);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local(""),
|
|
||||||
url("./Inter-Medium.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 3em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 60px;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: none;
|
|
||||||
margin: 0 0 0 20px;
|
|
||||||
padding: 0 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result {
|
|
||||||
height: 20px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
place-content: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #e80000aa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result {
|
|
||||||
height: 20px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 1rem;
|
|
||||||
align-content: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.input-box .btn:hover {
|
|
||||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .input {
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
color: black;
|
|
||||||
background-color: rgba(240, 240, 240, 1);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .input:hover {
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(255, 255, 255, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .input:focus {
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(255, 255, 255, 1);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 8.8 KiB |
168
client/ui/frontend/settings.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Settings — Tabs & Controls
|
||||||
|
|
||||||
|
Each row has a title and short description. Booleans default to **toggle switch**; pick another control only when noted.
|
||||||
|
|
||||||
|
Tab order: **General · Network · Security · SSH · Advanced · Troubleshooting · About**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. General
|
||||||
|
|
||||||
|
App behavior + how the client connects.
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- **Connect on startup** — `disableAutoConnect` (inverted) · *toggle switch*
|
||||||
|
- Automatically connect to NetBird when the app launches.
|
||||||
|
- **Show notifications** — `disableNotifications` (inverted) · *toggle switch*
|
||||||
|
- Show desktop notifications for connection events and updates.
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
|
||||||
|
- **Management Server** — `managementUrl` · *label + help text + (text input next to inline Save button)*
|
||||||
|
- Help text sits between the label and the input. The NetBird management server this client connects to; saving reconnects to apply the new server. Save button persists explicitly (in addition to the global debounced auto-save) since changing the server triggers a reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Network
|
||||||
|
|
||||||
|
Routing and DNS — how the daemon reaches peers and resolves names.
|
||||||
|
|
||||||
|
### Connectivity
|
||||||
|
|
||||||
|
- **Lazy connections** — `lazyConnectionEnabled` · *toggle switch*
|
||||||
|
- Only establish peer tunnels on first traffic instead of eagerly at startup.
|
||||||
|
- **Network monitor** — `networkMonitor` · *toggle switch*
|
||||||
|
- Reconnect automatically when the host network changes (Wi-Fi switch, VPN, sleep/wake).
|
||||||
|
|
||||||
|
### Routing & DNS
|
||||||
|
|
||||||
|
- **Enable DNS** — `disableDns` (inverted) · *toggle switch*
|
||||||
|
- Apply NetBird-managed DNS settings to the host resolver.
|
||||||
|
- **Enable client routes** — `disableClientRoutes` (inverted) · *toggle switch*
|
||||||
|
- Accept routes advertised by other peers so this client can reach their networks.
|
||||||
|
- **Enable server routes** — `disableServerRoutes` (inverted) · *toggle switch*
|
||||||
|
- Advertise this host's local routes to other peers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Security
|
||||||
|
|
||||||
|
Firewall and on-the-wire encryption — what's blocked and how the tunnel is protected.
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
|
||||||
|
- **Block inbound traffic** — `blockInbound` · *toggle switch*
|
||||||
|
- Drop all unsolicited inbound traffic on the NetBird interface.
|
||||||
|
- **Block LAN access** — `blockLanAccess` · *toggle switch*
|
||||||
|
- Prevent peers from reaching this host's local network.
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
- **Quantum-resistant encryption** — `rosenpassEnabled` · *toggle switch*
|
||||||
|
- Add a post-quantum key exchange (Rosenpass) on top of WireGuard.
|
||||||
|
- **Permissive mode** — `rosenpassPermissive` · *toggle switch* (nested, only when above is on)
|
||||||
|
- Allow connections to peers without quantum-resistance support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SSH
|
||||||
|
|
||||||
|
NetBird SSH server config. Master switch at the top; sub-toggles greyed out when the master is off.
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- **Allow SSH** — `serverSshAllowed` · *toggle switch* (master)
|
||||||
|
- Run the NetBird SSH server on this host so other peers can connect to it.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
- **Allow root login** — `enableSshRoot` · *toggle switch*
|
||||||
|
- Permit incoming SSH sessions to authenticate as `root`.
|
||||||
|
- **Enable SFTP** — `enableSshSftp` · *toggle switch*
|
||||||
|
- Allow file transfers over the NetBird SSH server.
|
||||||
|
- **Local port forwarding** — `enableSshLocalPortForwarding` · *toggle switch*
|
||||||
|
- Allow clients to forward local ports through this host.
|
||||||
|
- **Remote port forwarding** — `enableSshRemotePortForwarding` · *toggle switch*
|
||||||
|
- Allow clients to expose remote ports back through this host.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **Disable SSH auth** — `disableSshAuth` · *toggle switch*
|
||||||
|
- Skip JWT authentication for incoming SSH sessions. **Insecure — diagnostics only.**
|
||||||
|
- **JWT cache TTL** — `sshJwtCacheTtl` · *number input (seconds)*
|
||||||
|
- How long verified JWTs are cached before re-validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Advanced
|
||||||
|
|
||||||
|
Power-user knobs: tunnel security, interface tuning, and log verbosity.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Pre-shared key** — `preSharedKey` · *label + help text + password input with reveal toggle*
|
||||||
|
- Help text sits between the label and the input. Optional WireGuard pre-shared key for an extra layer of symmetric encryption; must match the value on every peer.
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
- **Name** — `interfaceName` · *text input*
|
||||||
|
- Name of the WireGuard network interface created on this host.
|
||||||
|
- **WireGuard Port** — `wireguardPort` · *number input*
|
||||||
|
- Local UDP port the WireGuard interface listens on.
|
||||||
|
- **MTU** — `mtu` · *number input*
|
||||||
|
- Maximum transmission unit for the WireGuard interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
Everything you reach for when something is wrong.
|
||||||
|
|
||||||
|
### Debug bundle
|
||||||
|
|
||||||
|
Friendly intro line on top: *"A debug bundle helps NetBird support investigate connection problems. It's a zip file with logs and system details from this device."*
|
||||||
|
|
||||||
|
Toggle rows:
|
||||||
|
|
||||||
|
- **Anonymize personal data** — `anonymize` · *toggle switch* · default **on**
|
||||||
|
- Replace IPs, hostnames, and peer names before saving.
|
||||||
|
- **Include system info** — `systemInfo` · *toggle switch* · default **on**
|
||||||
|
- Include OS, kernel, network interfaces, and routing tables.
|
||||||
|
- **Send to NetBird support** — *toggle switch* · default **off**
|
||||||
|
- Uploads the bundle to a hardcoded NetBird endpoint (`NETBIRD_UPLOAD_URL` constant). On success the user gets a short upload key to share with support. Local copy is always kept too.
|
||||||
|
- **Capture detailed (trace) logs** — *toggle switch* · default **off**
|
||||||
|
- Nested *Capture for [N] minutes* number input (1–30, suffix "min", default 3).
|
||||||
|
- When enabled, the daemon's log level is switched to trace, NetBird is brought down and back up, the UI captures for the configured duration, the original log level is restored, then the bundle is created with `logFileCount: 5` (vs 1 in plain mode).
|
||||||
|
- User-facing warning baked into the help text: "NetBird will briefly disconnect."
|
||||||
|
|
||||||
|
**Create bundle** — primary button. Disabled while running. Shows "Creating bundle…" label.
|
||||||
|
|
||||||
|
### Status / result block
|
||||||
|
|
||||||
|
Renders below the button while running and after completion.
|
||||||
|
|
||||||
|
- **Running** — bordered card with spinner + stage text. Stages: *Switching to trace logging…* → *Reconnecting NetBird…* → *Capturing logs — m:ss / m:ss* (countdown) → *Restoring previous log level…* → *Building bundle…* → *Uploading to NetBird…* (last only when upload toggle on; trace stages skipped when trace off).
|
||||||
|
- **Done — uploaded**: bordered card with the upload key in a copyable code block + "Share this key with NetBird support so they can find your bundle.". Below, a smaller card with the local path + Copy + Reveal (file://) buttons + admin-privilege note.
|
||||||
|
- **Done — local only**: single card with "Bundle saved to:" + path + Copy + Reveal + admin note.
|
||||||
|
- **Partial — upload failed**: red banner ("Upload failed: <reason>. The bundle is still saved locally.") above the local path card.
|
||||||
|
- **Error** (no bundle produced): red banner with the error message + a **Try again** button next to Create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. About
|
||||||
|
|
||||||
|
Two-row layout. Top row pairs the app icon with the product name + versions; everything else stacks below full-width.
|
||||||
|
|
||||||
|
**Top row** (icon left, info right):
|
||||||
|
|
||||||
|
1. **App icon** — `netbird-app-icon.svg`, `w-24 h-24`, rounded corners, subtle border (`border-nb-gray-800`).
|
||||||
|
2. **NetBird** heading + version lines:
|
||||||
|
- **GUI v{x.y.z}** — from `frontend/package.json` at build time
|
||||||
|
- **Client v{x.y.z}** — from `Status.daemonVersion`
|
||||||
|
|
||||||
|
**Below the top row**, in order:
|
||||||
|
|
||||||
|
3. **Update banner** *(visible only when an event in `Status.events` carries `metadata["new_version_available"]`)* — "Version X.Y.Z is available." + a **What's new?** link → GitHub release page for that version, plus a **Restart now** primary button → `Update.Trigger()`.
|
||||||
|
4. **Copyright** — "© {current year} NetBird. All Rights Reserved." (year from `new Date().getFullYear()`).
|
||||||
|
5. **Legal links** — Imprint · Privacy · CLA · Terms of Service. Each opens via Wails `Browser.OpenURL` with `window.open` fallback.
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
|
||||||
import Layout from "./Layout";
|
|
||||||
import Status from "./pages/Status";
|
|
||||||
import Settings from "./pages/Settings";
|
|
||||||
import Networks from "./pages/Networks";
|
|
||||||
import Peers from "./pages/Peers";
|
|
||||||
import Profiles from "./pages/Profiles";
|
|
||||||
import Debug from "./pages/Debug";
|
|
||||||
import Update from "./pages/Update";
|
|
||||||
import QuickActions from "./pages/QuickActions";
|
|
||||||
import LoginUrl from "./pages/LoginUrl";
|
|
||||||
import Login from "./pages/Login";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<HashRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/quick" element={<QuickActions />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/login-url" element={<LoginUrl />} />
|
|
||||||
<Route path="/update" element={<Update />} />
|
|
||||||
<Route element={<Layout />}>
|
|
||||||
<Route index element={<Status />} />
|
|
||||||
<Route path="peers" element={<Peers />} />
|
|
||||||
<Route path="networks" element={<Networks />} />
|
|
||||||
<Route path="profiles" element={<Profiles />} />
|
|
||||||
<Route path="settings" element={<Settings />} />
|
|
||||||
<Route path="debug" element={<Debug />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</HashRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { NavLink, Outlet } from "react-router-dom";
|
|
||||||
import { Activity, Bug, Network, Settings as SettingsIcon, Share2, Users } from "lucide-react";
|
|
||||||
import { cn } from "./lib/cn";
|
|
||||||
|
|
||||||
const nav = [
|
|
||||||
{ to: "/", label: "Status", icon: Activity, end: true },
|
|
||||||
{ to: "/peers", label: "Peers", icon: Share2 },
|
|
||||||
{ to: "/networks", label: "Networks", icon: Network },
|
|
||||||
{ to: "/profiles", label: "Profiles", icon: Users },
|
|
||||||
{ to: "/settings", label: "Settings", icon: SettingsIcon },
|
|
||||||
{ to: "/debug", label: "Debug", icon: Bug },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full">
|
|
||||||
<aside className="w-48 shrink-0 border-r border-nb-gray-200 bg-nb-gray-50 dark:border-nb-gray-800 dark:bg-nb-gray-940">
|
|
||||||
<div className="px-4 py-5 text-lg font-semibold text-netbird">NetBird</div>
|
|
||||||
<nav className="px-2">
|
|
||||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={end}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm",
|
|
||||||
isActive
|
|
||||||
? "bg-netbird/10 text-netbird"
|
|
||||||
: "text-nb-gray-700 hover:bg-nb-gray-100 dark:text-nb-gray-300 dark:hover:bg-nb-gray-900",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
<main className="flex-1 overflow-auto">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
37
client/ui/frontend/src/app.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import "./globals.css";
|
||||||
|
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import QuickActions from "@/screens/QuickActions.tsx";
|
||||||
|
import LoginUrl from "@/pages/LoginUrl.tsx";
|
||||||
|
import Update from "@/screens/Update.tsx";
|
||||||
|
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||||
|
import { Main } from "@/layouts/Main.tsx";
|
||||||
|
import { Settings } from "@/modules/settings/Settings.tsx";
|
||||||
|
import { SkeletonTheme } from "react-loading-skeleton";
|
||||||
|
import "react-loading-skeleton/dist/skeleton.css";
|
||||||
|
import { welcome } from "@/lib/welcome";
|
||||||
|
|
||||||
|
welcome();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||||
|
<HashRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/quick" element={<QuickActions />} />
|
||||||
|
<Route path="/login" element={<LoginUrl />} />
|
||||||
|
<Route path="/update" element={<Update />} />
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<Main />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={"/"} replace />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
</SkeletonTheme>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
BIN
client/ui/frontend/src/assets/fonts/InterVariable.ttf
Normal file
BIN
client/ui/frontend/src/assets/fonts/JetBrainsMonoVariable.ttf
Normal file
BIN
client/ui/frontend/src/assets/logos/fonts/inter.ttf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_8361_2)">
|
||||||
|
<rect width="350" height="350" fill="#FCFCFC"/>
|
||||||
|
<rect x="-32" y="237" width="422" height="113" fill="#FCFCFC"/>
|
||||||
|
<path d="M219.319 91.9371C191.917 94.453 178.279 110.24 173.125 118.228L93.0557 257.039H189.655L284.934 91.9371H219.319Z" fill="#F68330"/>
|
||||||
|
<path d="M189.731 257.041L58 117.224C58 117.224 206.952 77.1591 221.471 202.133L189.731 257.041Z" fill="#F68330"/>
|
||||||
|
<path d="M170.168 123.395L129.756 193.461L189.651 257.049L221.389 202.015C216.362 159.057 195.433 135.596 170.168 123.332" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_8361_2">
|
||||||
|
<rect width="350" height="350" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 755 B |
14
client/ui/frontend/src/assets/logos/netbird-app-icon.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_8281_6)">
|
||||||
|
<rect width="350" height="350" fill="#181A1C"/>
|
||||||
|
<rect x="-32" y="237" width="422" height="113" fill="#181A1C"/>
|
||||||
|
<path d="M219.319 91.9371C191.917 94.453 178.279 110.24 173.125 118.228L93.0557 257.039H189.655L284.934 91.9371H219.319Z" fill="#F68330"/>
|
||||||
|
<path d="M189.731 257.041L58 117.224C58 117.224 206.952 77.1591 221.471 202.133L189.731 257.041Z" fill="#F68330"/>
|
||||||
|
<path d="M170.168 123.395L129.756 193.461L189.651 257.049L221.389 202.015C216.362 159.057 195.433 135.596 170.168 123.332" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_8281_6">
|
||||||
|
<rect width="350" height="350" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 755 B |
19
client/ui/frontend/src/assets/logos/netbird-full.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_0_3)">
|
||||||
|
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
|
||||||
|
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_0_3">
|
||||||
|
<rect width="132.72" height="22.5186" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
5
client/ui/frontend/src/assets/logos/netbird.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
@@ -1,42 +1,167 @@
|
|||||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
import { cva, VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "../lib/cn";
|
import classNames from "classnames";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||||
|
|
||||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||||
type Size = "sm" | "md";
|
|
||||||
|
|
||||||
const variants: Record<Variant, string> = {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||||
primary: "bg-netbird text-white hover:bg-netbird-500 disabled:bg-nb-gray-300",
|
disabled?: boolean;
|
||||||
secondary:
|
stopPropagation?: boolean;
|
||||||
"bg-nb-gray-100 text-nb-gray-900 hover:bg-nb-gray-200 dark:bg-nb-gray-900 dark:text-nb-gray-50 dark:hover:bg-nb-gray-800",
|
copy?: string;
|
||||||
ghost:
|
|
||||||
"bg-transparent text-nb-gray-700 hover:bg-nb-gray-100 dark:text-nb-gray-200 dark:hover:bg-nb-gray-900",
|
|
||||||
danger: "bg-red-600 text-white hover:bg-red-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizes: Record<Size, string> = {
|
|
||||||
sm: "h-7 px-2 text-xs",
|
|
||||||
md: "h-9 px-3 text-sm",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: Variant;
|
|
||||||
size?: Size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
export const buttonVariants = cva(
|
||||||
{ variant = "primary", size = "md", className, ...rest },
|
[
|
||||||
ref,
|
"relative",
|
||||||
) {
|
"text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm",
|
||||||
return (
|
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||||
<button
|
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||||
ref={ref}
|
],
|
||||||
className={cn(
|
{
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
variants: {
|
||||||
variants[variant],
|
variant: {
|
||||||
sizes[size],
|
default: [
|
||||||
|
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||||
|
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||||
|
],
|
||||||
|
primary: [
|
||||||
|
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-900 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||||
|
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||||
|
],
|
||||||
|
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-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
|
||||||
|
],
|
||||||
|
secondaryLighter: [
|
||||||
|
"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/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
|
||||||
|
],
|
||||||
|
subtle: [
|
||||||
|
"bg-nb-gray-50 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60 border-nb-gray-200 text-nb-gray-900",
|
||||||
|
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
|
||||||
|
"dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:border-nb-gray-200 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
|
||||||
|
],
|
||||||
|
input: [
|
||||||
|
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||||
|
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||||
|
"dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80",
|
||||||
|
],
|
||||||
|
dropdown: [
|
||||||
|
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||||
|
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||||
|
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
|
||||||
|
],
|
||||||
|
dotted: [
|
||||||
|
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||||
|
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||||
|
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
|
||||||
|
],
|
||||||
|
tertiary: [
|
||||||
|
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||||
|
"dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
"focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||||
|
"disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900",
|
||||||
|
],
|
||||||
|
outline: [
|
||||||
|
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||||
|
"dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30",
|
||||||
|
],
|
||||||
|
"danger-outline": [
|
||||||
|
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
|
||||||
|
],
|
||||||
|
"danger-text": [
|
||||||
|
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
|
||||||
|
],
|
||||||
|
"default-outline": [
|
||||||
|
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||||
|
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||||
|
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||||
|
],
|
||||||
|
ghost: [
|
||||||
|
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||||
|
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30",
|
||||||
|
],
|
||||||
|
danger: [
|
||||||
|
"dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
xs: "text-xs py-2 px-3.5",
|
||||||
|
xs2: "text-[0.78rem] py-2 px-4",
|
||||||
|
sm: "text-sm py-[9px] px-4",
|
||||||
|
md: "text-md py-[9px] px-4",
|
||||||
|
lg: "text-lg py-[9px] px-4",
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
true: "rounded-md",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
0: "border",
|
||||||
|
1: "border border-transparent",
|
||||||
|
2: "border border-t-0 border-b-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
rounded = true,
|
||||||
|
border = 1,
|
||||||
|
size = "md",
|
||||||
|
stopPropagation = true,
|
||||||
|
type = "button",
|
||||||
|
children,
|
||||||
className,
|
className,
|
||||||
)}
|
onClick,
|
||||||
{...rest}
|
disabled,
|
||||||
/>
|
copy,
|
||||||
);
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const iconSize = size === "xs" ? 12 : 14;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
className={classNames(
|
||||||
|
buttonVariants({
|
||||||
|
variant,
|
||||||
|
rounded,
|
||||||
|
border: border ? 1 : 0,
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (stopPropagation) e.stopPropagation();
|
||||||
|
if (copy !== undefined) {
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(copy)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
onClick?.(e);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default Button;
|
||||||
|
|||||||
79
client/ui/frontend/src/components/CardNavItem.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { ComponentType, forwardRef } from "react";
|
||||||
|
import { motion, HTMLMotionProps } from "framer-motion";
|
||||||
|
import { LucideProps } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type Props = HTMLMotionProps<"button"> & {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
active?: boolean;
|
||||||
|
iconSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
|
||||||
|
function CardNavItem(
|
||||||
|
{
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
active = false,
|
||||||
|
iconSize = 15,
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={iconSize}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors duration-150",
|
||||||
|
active ? "text-nb-gray-200" : "text-nb-gray-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"min-w-0"}>
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"font-medium text-[0.81rem] truncate",
|
||||||
|
active ? "text-nb-gray-100" : "text-nb-gray-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium truncate",
|
||||||
|
active ? "text-nb-gray-300" : "text-nb-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
149
client/ui/frontend/src/components/Dialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
ElementRef,
|
||||||
|
HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export const Root = DialogPrimitive.Root;
|
||||||
|
export const Trigger = DialogPrimitive.Trigger;
|
||||||
|
export const Close = DialogPrimitive.Close;
|
||||||
|
export const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export const Overlay = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(function DialogOverlay({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 grid place-items-start overflow-y-auto py-16",
|
||||||
|
"bg-black/40 backdrop-blur-sm",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||||
|
"duration-150 ease-out",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ scrollbarGutter: "stable both-edges" }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
|
showClose?: boolean;
|
||||||
|
maxWidthClass?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Content = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
ContentProps
|
||||||
|
>(function DialogContent(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showClose = true,
|
||||||
|
maxWidthClass = "max-w-md",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<Overlay>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto relative z-[52] w-full outline-none ring-0",
|
||||||
|
"focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0",
|
||||||
|
"border border-nb-gray-900 bg-nb-gray py-6 shadow-2xl rounded-lg",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
|
"data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1",
|
||||||
|
"duration-150 ease-out",
|
||||||
|
maxWidthClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<VisuallyHidden asChild>
|
||||||
|
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||||
|
</VisuallyHidden>
|
||||||
|
{children}
|
||||||
|
{showClose && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
className={cn(
|
||||||
|
"absolute right-4 top-4 z-10 rounded-sm opacity-70 transition-opacity",
|
||||||
|
"hover:opacity-100 focus:outline-none disabled:pointer-events-none",
|
||||||
|
"text-nb-gray-300",
|
||||||
|
)}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Overlay>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Title = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(function DialogTitle({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-md font-semibold leading-none tracking-tight text-nb-gray-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Description = forwardRef<
|
||||||
|
ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(function DialogDescription({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-nb-gray-400 mt-2 leading-snug",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type FooterProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
separator?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Footer = ({
|
||||||
|
className,
|
||||||
|
separator = true,
|
||||||
|
...props
|
||||||
|
}: FooterProps) => (
|
||||||
|
<div className={cn(separator && "border-t border-nb-gray-900 mt-6")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3",
|
||||||
|
"px-8 pt-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
75
client/ui/frontend/src/components/FancyToggleSwitch.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { HelpText } from "@/components/HelpText";
|
||||||
|
import { Label } from "@/components/Label";
|
||||||
|
import { ToggleSwitch } from "@/components/ToggleSwitch";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
helpText?: React.ReactNode;
|
||||||
|
label?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
dataCy?: string;
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
textWrapperClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FancyToggleSwitch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
helpText,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
dataCy,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
textWrapperClassName = "max-w-lg",
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
onChange(!value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleToggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={-1}
|
||||||
|
role={"switch"}
|
||||||
|
aria-checked={value}
|
||||||
|
className={cn(
|
||||||
|
"cursor-default transition-all duration-300 relative z-[1]",
|
||||||
|
"inline-block text-left w-full",
|
||||||
|
disabled && "opacity-30 pointer-events-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={"flex justify-between gap-10"}>
|
||||||
|
<div className={cn(textWrapperClassName)}>
|
||||||
|
<Label className={labelClassName}>{label}</Label>
|
||||||
|
<HelpText margin={false}>{helpText}</HelpText>
|
||||||
|
</div>
|
||||||
|
<div className={"mt-2 pr-1"}>
|
||||||
|
<ToggleSwitch checked={value} onCheckedChange={onChange} dataCy={dataCy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children && value ? (
|
||||||
|
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
client/ui/frontend/src/components/HelpText.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: ReactNode;
|
||||||
|
margin?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HelpText = ({ children, margin = true, className }: Props) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide",
|
||||||
|
margin && "mb-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HelpText;
|
||||||
41
client/ui/frontend/src/components/IconButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ComponentType, forwardRef } from "react";
|
||||||
|
import { motion, HTMLMotionProps } from "framer-motion";
|
||||||
|
import { LucideProps } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type Props = HTMLMotionProps<"button"> & {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
iconSize?: number;
|
||||||
|
iconClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
|
function IconButton(
|
||||||
|
{
|
||||||
|
icon: Icon,
|
||||||
|
iconSize = 18,
|
||||||
|
iconClassName,
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-11 flex items-center justify-center rounded-md cursor-default outline-none",
|
||||||
|
"text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-930",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon size={iconSize} className={iconClassName} />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,33 +1,250 @@
|
|||||||
import { InputHTMLAttributes, forwardRef } from "react";
|
import { cva, VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "../lib/cn";
|
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||||
|
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Label } from "@/components/Label";
|
||||||
|
|
||||||
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
type InputVariants = VariantProps<typeof inputVariants>;
|
||||||
label?: string;
|
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, InputVariants {
|
||||||
|
label?: string;
|
||||||
|
customPrefix?: ReactNode;
|
||||||
|
customSuffix?: ReactNode;
|
||||||
|
maxWidthClass?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
error?: string;
|
||||||
|
prefixClassName?: string;
|
||||||
|
showPasswordToggle?: boolean;
|
||||||
|
copy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, Props>(function Input(
|
const inputVariants = cva("", {
|
||||||
{ label, className, id, ...rest },
|
variants: {
|
||||||
ref,
|
variant: {
|
||||||
) {
|
default: [
|
||||||
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700",
|
||||||
return (
|
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
|
||||||
<div className="flex flex-col gap-1">
|
],
|
||||||
{label && (
|
darker: [
|
||||||
<label htmlFor={inputId} className="text-xs font-medium text-nb-gray-600 dark:text-nb-gray-300">
|
"dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-800",
|
||||||
{label}
|
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
|
||||||
</label>
|
],
|
||||||
)}
|
error: [
|
||||||
<input
|
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
|
||||||
id={inputId}
|
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
|
||||||
ref={ref}
|
],
|
||||||
className={cn(
|
},
|
||||||
"h-9 rounded-md border border-nb-gray-300 bg-white px-3 text-sm",
|
prefixSuffixVariant: {
|
||||||
"focus:border-netbird focus:outline-none focus:ring-1 focus:ring-netbird",
|
default: [
|
||||||
"dark:border-nb-gray-700 dark:bg-nb-gray-925 dark:text-nb-gray-50",
|
"dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300",
|
||||||
className,
|
],
|
||||||
)}
|
error: ["dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"],
|
||||||
{...rest}
|
},
|
||||||
/>
|
},
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
customSuffix,
|
||||||
|
customPrefix,
|
||||||
|
icon,
|
||||||
|
maxWidthClass = "",
|
||||||
|
error,
|
||||||
|
variant = "default",
|
||||||
|
prefixClassName,
|
||||||
|
showPasswordToggle = false,
|
||||||
|
copy = false,
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const isPasswordType = type === "password";
|
||||||
|
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||||
|
const isNumber = type === "number";
|
||||||
|
|
||||||
|
const reactId = useId();
|
||||||
|
const inputId = id ?? (label ? `input-${reactId}` : undefined);
|
||||||
|
|
||||||
|
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const setRefs = (el: HTMLInputElement | null) => {
|
||||||
|
internalRef.current = el;
|
||||||
|
if (typeof ref === "function") ref(el);
|
||||||
|
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepBy = (delta: 1 | -1) => {
|
||||||
|
const el = internalRef.current;
|
||||||
|
if (!el || el.disabled || el.readOnly) return;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
const stepAttr = el.step !== "" ? Number(el.step) : 1;
|
||||||
|
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||||
|
const min = el.min !== "" ? Number(el.min) : -Infinity;
|
||||||
|
const max = el.max !== "" ? Number(el.max) : Infinity;
|
||||||
|
const current = el.value === "" ? 0 : Number(el.value);
|
||||||
|
let next = (Number.isFinite(current) ? current : 0) + delta * step;
|
||||||
|
if (next < min) next = min;
|
||||||
|
if (next > max) next = max;
|
||||||
|
setter?.call(el, String(next));
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordToggle =
|
||||||
|
isPasswordType && showPasswordToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((s) => !s)}
|
||||||
|
className="hover:text-white transition-all pointer-events-auto"
|
||||||
|
aria-label="Toggle password visibility"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const onCopy = async () => {
|
||||||
|
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToggle = copy ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="hover:text-white transition-all pointer-events-auto"
|
||||||
|
aria-label="Copy"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const suffix = passwordToggle || copyToggle || customSuffix;
|
||||||
|
const showStepper = isNumber;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full min-w-0">
|
||||||
|
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||||
|
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
|
||||||
|
{customPrefix && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
inputVariants({
|
||||||
|
prefixSuffixVariant: error ? "error" : "default",
|
||||||
|
}),
|
||||||
|
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||||
|
"border items-center whitespace-nowrap",
|
||||||
|
props.disabled && "opacity-40",
|
||||||
|
prefixClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{customPrefix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
|
||||||
|
props.disabled && "opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex flex-grow min-w-0">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type={inputType}
|
||||||
|
ref={setRefs}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
inputVariants({
|
||||||
|
variant: error ? "error" : variant,
|
||||||
|
}),
|
||||||
|
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm",
|
||||||
|
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||||
|
customPrefix && "!border-l-0 !rounded-l-none",
|
||||||
|
suffix && "!pr-9",
|
||||||
|
icon && "!pl-10",
|
||||||
|
"border",
|
||||||
|
props.readOnly &&
|
||||||
|
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
|
||||||
|
showStepper &&
|
||||||
|
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{suffix && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
|
||||||
|
props.disabled && "opacity-30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suffix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showStepper && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col h-[40px] shrink-0 overflow-hidden",
|
||||||
|
"border border-l-0 rounded-r-md",
|
||||||
|
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||||
|
error && "dark:border-red-500",
|
||||||
|
props.disabled && "opacity-40 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Increase"
|
||||||
|
onClick={() => stepBy(1)}
|
||||||
|
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
|
||||||
|
>
|
||||||
|
<ChevronUp size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Decrease"
|
||||||
|
onClick={() => stepBy(-1)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
|
||||||
|
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Input;
|
||||||
|
|||||||
40
client/ui/frontend/src/components/Label.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-100 flex items-center gap-2",
|
||||||
|
);
|
||||||
|
|
||||||
|
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants> & {
|
||||||
|
as?: "label" | "div";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
|
||||||
|
{ className, as = "label", children, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const classes = cn(labelVariants(), className, "select-none");
|
||||||
|
|
||||||
|
if (as === "div") {
|
||||||
|
return (
|
||||||
|
<div ref={ref as Ref<HTMLDivElement>} className={classes}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref as Ref<HTMLLabelElement>}
|
||||||
|
className={classes}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LabelPrimitive.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Label;
|
||||||
130
client/ui/frontend/src/components/NetBirdConnectToggle.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||||
|
|
||||||
|
export enum ConnectionState {
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
Connecting = "connecting",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnecting = "disconnecting",
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateProps = {
|
||||||
|
state: ConnectionState;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NetBirdConnectToggleProps = {
|
||||||
|
state: ConnectionState;
|
||||||
|
size?: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConnectToggleProps) => {
|
||||||
|
const [visualState, setVisualState] = useState(state);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisualState(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (visualState === ConnectionState.Connected) {
|
||||||
|
setVisualState(ConnectionState.Disconnecting);
|
||||||
|
} else {
|
||||||
|
setVisualState(ConnectionState.Connecting);
|
||||||
|
}
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const padding = size * 0.075;
|
||||||
|
const borderGap = 2;
|
||||||
|
const borderInset = padding - borderGap;
|
||||||
|
const innerSize = size * 0.7;
|
||||||
|
const logoSize = size * 0.26;
|
||||||
|
const pingInset = size * 0.075;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<motion.button
|
||||||
|
className="rounded-full relative overflow-visible cursor-default outline-none border-none bg-transparent"
|
||||||
|
style={{ padding }}
|
||||||
|
onClick={handleClick}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||||
|
>
|
||||||
|
<OuterRing state={visualState} />
|
||||||
|
<BorderInnerRing state={visualState} inset={borderInset} />
|
||||||
|
<InnerRing size={innerSize}>
|
||||||
|
<NetBirdLogo state={visualState} logoSize={logoSize} />
|
||||||
|
<PingRing state={visualState} inset={pingInset} />
|
||||||
|
</InnerRing>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OuterRing = ({ state }: StateProps) => {
|
||||||
|
const isActive = state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-full transition-all",
|
||||||
|
isActive ? "bg-netbird-500/20" : "bg-neutral-700",
|
||||||
|
state === ConnectionState.Disconnecting && "animate-pulse-slow",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BorderInnerRing = ({ state, inset }: StateProps & { inset: number }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute rounded-full transition-all duration-1000",
|
||||||
|
state === ConnectionState.Connected && "bg-netbird-600",
|
||||||
|
state === ConnectionState.Disconnecting && "bg-conic-netbird animate-spin-slow",
|
||||||
|
state !== ConnectionState.Connected && state !== ConnectionState.Disconnecting && "bg-neutral-500",
|
||||||
|
)}
|
||||||
|
style={{ inset }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InnerRing = ({ children, size }: { children: React.ReactNode; size: number }) => (
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-nb-gray flex items-center justify-center relative z-10 mx-auto"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NetBirdLogo = ({ state, logoSize }: StateProps & { logoSize: number }) => {
|
||||||
|
const isConnecting = state === ConnectionState.Connecting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(isConnecting && "animate-pulse-slow")}
|
||||||
|
style={isConnecting ? { animationDelay: "0.1s" } : undefined}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={netbirdLogo}
|
||||||
|
alt="NetBird"
|
||||||
|
width={logoSize}
|
||||||
|
className={cn(
|
||||||
|
"filter transition-all duration-1000",
|
||||||
|
state === ConnectionState.Disconnected ? "grayscale" : "grayscale-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PingRing = ({ state, inset }: StateProps & { inset: number }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block absolute border-2 border-netbird rounded-full",
|
||||||
|
state === ConnectionState.Connecting ? "animate-ping-slow" : "hidden",
|
||||||
|
)}
|
||||||
|
style={{ inset }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
91
client/ui/frontend/src/components/NetBirdVersionCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Browser } from "@wailsio/runtime";
|
||||||
|
import { Update as UpdateSvc } from "@bindings/services";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { useStatus } from "@/hooks/useStatus";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
function openUrl(url: string) {
|
||||||
|
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastChecked(date: Date) {
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpdate() {
|
||||||
|
UpdateSvc.Trigger().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetBirdVersionCard() {
|
||||||
|
const { status } = useStatus();
|
||||||
|
const updateVersion = (status?.events ?? [])
|
||||||
|
.map((e) => e.metadata?.["new_version_available"])
|
||||||
|
.find((v): v is string => Boolean(v));
|
||||||
|
|
||||||
|
if (updateVersion) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div>
|
||||||
|
<Title>Version {updateVersion} is available.</Title>
|
||||||
|
<Link
|
||||||
|
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
|
||||||
|
>
|
||||||
|
What's new?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||||
|
Restart Now
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={"max-w-md"}>
|
||||||
|
<div>
|
||||||
|
<Title>Last checked on {formatLastChecked(new Date())}</Title>
|
||||||
|
<Link url={"https://github.com/netbirdio/netbird/releases/latest"}>Changelog</Link>
|
||||||
|
</div>
|
||||||
|
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||||
|
Check for updates
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-md flex items-center justify-between gap-4 rounded-md border border-nb-gray-800 bg-nb-gray-910 px-4 py-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Title({ children }: { children: ReactNode }) {
|
||||||
|
return <p className={"text-sm font-semibold"}>{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Link({ url, children }: { url: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={"button"}
|
||||||
|
onClick={() => openUrl(url)}
|
||||||
|
className={
|
||||||
|
"text-sm text-netbird hover:underline hover:underline-offset-4 hover:decoration-[0.5px] font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
client/ui/frontend/src/components/NewProfileDialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import * as Dialog from "@/components/Dialog";
|
||||||
|
import { Input } from "@/components/Input";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCreate: (name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setName("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const canSubmit = trimmed.length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onCreate(trimmed);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Content
|
||||||
|
maxWidthClass="max-w-md"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="px-8 pt-2">
|
||||||
|
<Dialog.Title>New Profile</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Profiles let you keep separate NetBird connections
|
||||||
|
side by side. Give your profile a memorable name.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-8 pt-3">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. Work"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
359
client/ui/frontend/src/components/ProfileSelector.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
import { Dialogs } from "@wailsio/runtime";
|
||||||
|
import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { generateColorFromString } from "@/lib/color";
|
||||||
|
import { NewProfileDialog } from "@/components/NewProfileDialog";
|
||||||
|
|
||||||
|
export type Profile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_PROFILES: Profile[] = [
|
||||||
|
{ id: "default", name: "Default Profile" },
|
||||||
|
{ id: "work", name: "Work" },
|
||||||
|
{ id: "personal", name: "Personal" },
|
||||||
|
{ id: "staging", name: "Staging" },
|
||||||
|
{ id: "production", name: "Production" },
|
||||||
|
{ id: "dev", name: "Development" },
|
||||||
|
{ id: "qa", name: "QA Environment" },
|
||||||
|
{ id: "demo", name: "Demo" },
|
||||||
|
{ id: "client-acme", name: "Client - ACME" },
|
||||||
|
{ id: "client-globex", name: "Client - Globex" },
|
||||||
|
{ id: "client-initech", name: "Client - Initech" },
|
||||||
|
{ id: "homelab", name: "Homelab" },
|
||||||
|
{ id: "office-berlin", name: "Office Berlin" },
|
||||||
|
{ id: "office-sf", name: "Office San Francisco" },
|
||||||
|
{ id: "office-tokyo", name: "Office Tokyo" },
|
||||||
|
{ id: "vpn-eu", name: "VPN EU" },
|
||||||
|
{ id: "vpn-us", name: "VPN US" },
|
||||||
|
{ id: "vpn-asia", name: "VPN Asia" },
|
||||||
|
{ id: "test", name: "Test" },
|
||||||
|
{ id: "sandbox", name: "Sandbox" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileSelector = ({ email = "" }: Props) => {
|
||||||
|
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
|
||||||
|
const [selectedId, setSelectedId] = useState<string>(MOCK_PROFILES[0].id);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
|
||||||
|
const selected = profiles.find((p) => p.id === selectedId) ?? profiles[0];
|
||||||
|
|
||||||
|
const sorted = [...profiles].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeregister = async (id: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
const result = await Dialogs.Warning({
|
||||||
|
Title: "Deregister Profile",
|
||||||
|
Message: `Are you sure you want to deregister "${profile.name}"? You will need to log in again to use it.`,
|
||||||
|
Buttons: [
|
||||||
|
{ Label: "Cancel", IsCancel: true },
|
||||||
|
{ Label: "Deregister", IsDefault: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (result !== "Deregister") return;
|
||||||
|
console.log("Deregister profile", id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
const result = await Dialogs.Warning({
|
||||||
|
Title: "Delete Profile",
|
||||||
|
Message: `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`,
|
||||||
|
Buttons: [
|
||||||
|
{ Label: "Cancel", IsCancel: true },
|
||||||
|
{ Label: "Delete", IsDefault: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (result !== "Delete") return;
|
||||||
|
setProfiles((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
if (selectedId === id) {
|
||||||
|
const remaining = profiles.filter((p) => p.id !== id);
|
||||||
|
if (remaining.length > 0) setSelectedId(remaining[0].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewProfile = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setNewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProfile = (name: string) => {
|
||||||
|
const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`;
|
||||||
|
setProfiles((prev) => [...prev, { id, name }]);
|
||||||
|
setSelectedId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = selected?.name.charAt(0).toUpperCase() ?? "?";
|
||||||
|
const initialColor = generateColorFromString(selected?.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
"h-11 rounded-md text-nb-gray-300 flex items-center gap-1 text-xs hover:bg-nb-gray-930 data-[state=open]:bg-nb-gray-930 px-2 -mx-1 outline-none cursor-default transition-colors duration-150"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold",
|
||||||
|
email ? "h-7 w-7" : "h-6 w-6",
|
||||||
|
)}
|
||||||
|
style={{ color: initialColor }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap flex flex-col ml-1 text-left",
|
||||||
|
email ? "mt-1" : "justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={"leading-none text-nb-gray-200 font-semibold"}>
|
||||||
|
{selected?.name ?? "No profile"}
|
||||||
|
</span>
|
||||||
|
{email && (
|
||||||
|
<span className={"text-[0.73rem] font-normal text-nb-gray-300"}>
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={14} className={"ml-2 mr-2"} />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="end"
|
||||||
|
sideOffset={6}
|
||||||
|
className={cn(
|
||||||
|
"w-72 rounded-md border border-nb-gray-900 bg-nb-gray-930 shadow-lg",
|
||||||
|
"p-1 z-50 origin-[var(--radix-popover-content-transform-origin)]",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||||
|
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||||
|
"data-[side=bottom]:slide-in-from-top-1",
|
||||||
|
"data-[side=top]:slide-in-from-bottom-1",
|
||||||
|
"duration-150 ease-out",
|
||||||
|
)}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
loop
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col",
|
||||||
|
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="px-1 pb-1">
|
||||||
|
<div className="group flex items-center gap-2 px-2 h-8">
|
||||||
|
<Search size={12} className="text-nb-gray-300 shrink-0" />
|
||||||
|
<Command.Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search profile by name..."
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||||
|
"outline-none border-none",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||||
|
<ScrollArea.Viewport className="max-h-64 px-1 pb-1">
|
||||||
|
<Command.List>
|
||||||
|
<Command.Empty>
|
||||||
|
<div className="flex flex-col items-center text-center px-4 pt-2 pb-3">
|
||||||
|
<h3 className="text-xs font-semibold text-nb-gray-200">
|
||||||
|
No Profiles Found
|
||||||
|
</h3>
|
||||||
|
<p className="text-[0.7rem] leading-snug text-nb-gray-400 mt-1 text-balance">
|
||||||
|
Try a different search term or create a new
|
||||||
|
profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
{sorted.map((profile) => (
|
||||||
|
<ProfileRow
|
||||||
|
key={profile.id}
|
||||||
|
profile={profile}
|
||||||
|
selected={profile.id === selectedId}
|
||||||
|
onSelect={() => handleSelect(profile.id)}
|
||||||
|
onDeregister={() => handleDeregister(profile.id)}
|
||||||
|
onDelete={() => handleDelete(profile.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Command.List>
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Scrollbar
|
||||||
|
orientation="vertical"
|
||||||
|
className={cn(
|
||||||
|
"flex select-none touch-none transition-colors",
|
||||||
|
"w-1.5 bg-transparent py-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||||
|
</ScrollArea.Scrollbar>
|
||||||
|
</ScrollArea.Root>
|
||||||
|
|
||||||
|
<div className="h-px bg-nb-gray-920 -mx-1 my-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewProfile}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
|
||||||
|
"text-netbird hover:bg-nb-gray-910",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"h-6 w-6 flex items-center justify-center rounded-md bg-nb-gray-900 shrink-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlusCircle size={12} className="text-netbird" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold">New Profile</span>
|
||||||
|
</button>
|
||||||
|
</Command>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
<NewProfileDialog
|
||||||
|
open={newOpen}
|
||||||
|
onOpenChange={setNewOpen}
|
||||||
|
onCreate={handleCreateProfile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfileRowProps = {
|
||||||
|
profile: Profile;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDeregister: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: ProfileRowProps) => {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const initial = profile.name.charAt(0).toUpperCase();
|
||||||
|
const initialColor = generateColorFromString(profile.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
value={profile.name}
|
||||||
|
keywords={[profile.id]}
|
||||||
|
onSelect={() => onSelect()}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
|
||||||
|
"data-[selected=true]:bg-nb-gray-910",
|
||||||
|
selected && "bg-nb-gray-910",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 flex items-center justify-center rounded-md text-[0.65rem] font-semibold shrink-0 bg-nb-gray-900",
|
||||||
|
"group-data-[selected=true]:bg-nb-gray-850",
|
||||||
|
selected && "bg-nb-gray-850",
|
||||||
|
)}
|
||||||
|
style={{ color: initialColor }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-1 truncate text-xs",
|
||||||
|
selected ? "text-nb-gray-200 font-semibold" : "text-nb-gray-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{profile.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 flex items-center justify-center rounded text-nb-gray-400 cursor-default",
|
||||||
|
"hover:bg-nb-gray-800 hover:text-nb-gray-200 outline-none",
|
||||||
|
"data-[state=open]:bg-nb-gray-800 data-[state=open]:text-nb-gray-200",
|
||||||
|
)}
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
"w-44 rounded-md border border-nb-gray-850 bg-nb-gray-910 shadow-lg p-1 z-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDeregister();
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
|
||||||
|
"text-xs text-nb-gray-200 data-[highlighted]:bg-nb-gray-850",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserMinus size={14} className="text-nb-gray-300" />
|
||||||
|
<span>Deregister</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDelete();
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
|
||||||
|
"text-xs text-red-500 data-[highlighted]:bg-nb-gray-850",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>Delete Profile</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
client/ui/frontend/src/components/SearchInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, InputHTMLAttributes } from "react";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
iconSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, Props>(
|
||||||
|
function SearchInput({ iconSize = 14, className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div className={"flex items-center gap-2 px-2 h-9"}>
|
||||||
|
<SearchIcon
|
||||||
|
size={iconSize}
|
||||||
|
className={"text-nb-gray-300 shrink-0"}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={"text"}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||||
|
"outline-none border-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
48
client/ui/frontend/src/components/StatusPanel.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Check, Loader2, XCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type Variant = "loading" | "success" | "error";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
variant: Variant;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARIANTS: Record<Variant, { icon: ReactNode; className: string }> = {
|
||||||
|
loading: {
|
||||||
|
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
|
||||||
|
className: "bg-nb-gray-100",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: <Check className={"text-white"} size={18} />,
|
||||||
|
className: "bg-green-500",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: <XCircle className={"text-white"} size={18} />,
|
||||||
|
className: "bg-red-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusPanel({ variant, title, description, children, actions }: Props) {
|
||||||
|
const { icon, className } = VARIANTS[variant];
|
||||||
|
return (
|
||||||
|
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
|
||||||
|
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
|
||||||
|
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
|
||||||
|
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
|
||||||
|
|
||||||
|
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
client/ui/frontend/src/components/SwitchItem.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { useSwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SwitchItem = ({ value, children }: Props) => {
|
||||||
|
const { value: activeValue, layoutId } = useSwitchItemGroup();
|
||||||
|
const active = activeValue === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroup.Item
|
||||||
|
value={value}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
|
||||||
|
"outline-none cursor-default",
|
||||||
|
active
|
||||||
|
? "text-nb-gray-100"
|
||||||
|
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId={layoutId}
|
||||||
|
className={"absolute inset-0 rounded-md bg-nb-gray-700"}
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={"relative inline-flex items-center justify-center gap-1"}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
client/ui/frontend/src/components/SwitchItemGroup.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
|
import { createContext, ReactNode, useContext, useId } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type SwitchItemGroupContextValue = {
|
||||||
|
value: string;
|
||||||
|
layoutId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useSwitchItemGroup = () => {
|
||||||
|
const ctx = useContext(SwitchItemGroupContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("SwitchItem must be used inside a SwitchItemGroup");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SwitchItemGroup = ({ value, onChange, children, className }: Props) => {
|
||||||
|
const layoutId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwitchItemGroupContext.Provider value={{ value, layoutId }}>
|
||||||
|
<RadioGroup.Root
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</SwitchItemGroupContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
77
client/ui/frontend/src/components/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import { cva, VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type SwitchVariants = VariantProps<typeof switchVariants>;
|
||||||
|
|
||||||
|
const switchVariants = cva("", {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
default: "h-[24px] w-[44px]",
|
||||||
|
small: "h-[18px] w-[36px]",
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
default: [
|
||||||
|
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||||
|
"dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||||
|
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
|
||||||
|
"data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
|
||||||
|
],
|
||||||
|
"red-green": [
|
||||||
|
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||||
|
"dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||||
|
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
|
||||||
|
"data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||||
|
"dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||||
|
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
|
||||||
|
"data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"thumb-size": {
|
||||||
|
default: "h-5 w-5 data-[state=checked]:translate-x-5",
|
||||||
|
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToggleSwitch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
|
||||||
|
SwitchVariants & { dataCy?: string }
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, size = "default", variant = "default", dataCy, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||||
|
className,
|
||||||
|
switchVariants({ size, variant }),
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
data-cy={dataCy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onClick?.(e);
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
switchVariants({ "thumb-size": size }),
|
||||||
|
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { ToggleSwitch };
|
||||||
87
client/ui/frontend/src/components/VerticalTabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { ComponentType, forwardRef } from "react";
|
||||||
|
import * as Tabs from "@radix-ui/react-tabs";
|
||||||
|
import { LucideProps } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
const Root = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<Tabs.TabsProps, "orientation">
|
||||||
|
>(function VerticalTabsRoot({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<Tabs.Root
|
||||||
|
ref={ref}
|
||||||
|
orientation={"vertical"}
|
||||||
|
className={cn("flex flex-1 min-h-0 gap-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
|
||||||
|
function VerticalTabsList({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<Tabs.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full flex flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type TriggerProps = Tabs.TabsTriggerProps & {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
title: string;
|
||||||
|
iconSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||||
|
function VerticalTabsTrigger(
|
||||||
|
{ icon: Icon, title, iconSize = 16, className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Tabs.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
"data-[state=active]:bg-nb-gray-930",
|
||||||
|
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={iconSize}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 ml-2 transition-colors duration-150",
|
||||||
|
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
|
||||||
|
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
|
||||||
|
function VerticalTabsContent({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<Tabs.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn("outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
|
||||||
29
client/ui/frontend/src/globals.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Inter Variable";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src: url("./assets/fonts/InterVariable.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wails-draggable {
|
||||||
|
--wails-draggable: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wails-no-draggable {
|
||||||
|
--wails-draggable: no-drag;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Events } from "@wailsio/runtime";
|
import { Events } from "@wailsio/runtime";
|
||||||
import { Peers } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
import { Peers } from "@bindings/services";
|
||||||
import type { Status } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { Status } from "@bindings/services/models.js";
|
||||||
|
|
||||||
const EVENT_STATUS = "netbird:status";
|
const EVENT_STATUS = "netbird:status";
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-white text-nb-gray-900 antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark body {
|
|
||||||
@apply bg-nb-gray-950 text-nb-gray-50;
|
|
||||||
}
|
|
||||||
19
client/ui/frontend/src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Header } from "@/layouts/Header.tsx";
|
||||||
|
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner.tsx";
|
||||||
|
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||||
|
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
||||||
|
|
||||||
|
export const AppLayout = () => {
|
||||||
|
return (
|
||||||
|
<ProfileProvider>
|
||||||
|
<DebugBundleProvider>
|
||||||
|
<div className={"relative flex h-full flex-col"}>
|
||||||
|
<Header />
|
||||||
|
<Outlet />
|
||||||
|
<UpdateAvailableBanner />
|
||||||
|
</div>
|
||||||
|
</DebugBundleProvider>
|
||||||
|
</ProfileProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
client/ui/frontend/src/layouts/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
NetBirdConnectToggle,
|
||||||
|
} from "@/components/NetBirdConnectToggle.tsx";
|
||||||
|
|
||||||
|
export const ConnectionStatus = () => {
|
||||||
|
return (
|
||||||
|
<div className={"flex flex-col h-full items-center justify-center"}>
|
||||||
|
<NetBirdConnectToggle state={ConnectionState.Connected} />
|
||||||
|
<h1
|
||||||
|
className={
|
||||||
|
"text-base font-medium mt-8 text-nb-gray-200 tracking-wide"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Connected
|
||||||
|
</h1>
|
||||||
|
<p className={"font-mono text-xs text-nb-gray-300 mt-1"}>
|
||||||
|
peer-hostname.netbird.cloud
|
||||||
|
</p>
|
||||||
|
<p className={"font-mono text-xs text-nb-gray-300 mt-0.5"}>
|
||||||
|
192.168.0.1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
31
client/ui/frontend/src/layouts/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { SettingsIcon } from "lucide-react";
|
||||||
|
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
|
||||||
|
import { IconButton } from "@/components/IconButton.tsx";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export const Header = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const isSettingsPage = location.pathname.startsWith("/settings");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"pt-4 shrink-0 cursor-default wails-draggable flex items-center justify-end px-4 gap-3 bg-gradient-to-b from-nb-gray-800/15"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"ml-20"}>
|
||||||
|
<ProfileSelector email={"eduard@netbird.io"} />
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon={SettingsIcon}
|
||||||
|
onClick={() => navigate(isSettingsPage ? "/" : "/settings")}
|
||||||
|
className={cn(
|
||||||
|
isSettingsPage &&
|
||||||
|
"bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
client/ui/frontend/src/layouts/Main.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
|
||||||
|
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||||
|
import { Navigation } from "@/layouts/Navigation.tsx";
|
||||||
|
import { Peers } from "@/modules/peers/Peers.tsx";
|
||||||
|
|
||||||
|
export const Main = () => {
|
||||||
|
return (
|
||||||
|
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex flex-col max-w-xs w-full shrink-0 items-center"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConnectionStatus />
|
||||||
|
<Navigation peersActive />
|
||||||
|
</div>
|
||||||
|
<MainRightSide>
|
||||||
|
<Peers />
|
||||||
|
</MainRightSide>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
client/ui/frontend/src/layouts/MainRightSide.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/cn.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainRightSide = ({ children }: Props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"wails-no-draggable",
|
||||||
|
"bg-nb-gray-935 border border-nb-gray-910",
|
||||||
|
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
client/ui/frontend/src/layouts/Navigation.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
||||||
|
import {
|
||||||
|
Layers3Icon,
|
||||||
|
MonitorSmartphoneIcon,
|
||||||
|
SquareArrowUpRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peersActive?: boolean;
|
||||||
|
onPeersClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
|
||||||
|
return (
|
||||||
|
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
||||||
|
<CardNavItem
|
||||||
|
icon={MonitorSmartphoneIcon}
|
||||||
|
title={"Peers"}
|
||||||
|
description={"13 of 16 Online"}
|
||||||
|
active={peersActive}
|
||||||
|
onClick={onPeersClick}
|
||||||
|
/>
|
||||||
|
<CardNavItem
|
||||||
|
icon={Layers3Icon}
|
||||||
|
title={"Resources"}
|
||||||
|
description={"13 of 16 Active"}
|
||||||
|
iconSize={14}
|
||||||
|
/>
|
||||||
|
<CardNavItem
|
||||||
|
icon={SquareArrowUpRight}
|
||||||
|
title={"Exit Node Berlin"}
|
||||||
|
description={"192.168..."}
|
||||||
|
iconSize={14}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
client/ui/frontend/src/lib/MainModuleContext.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createContext, ReactNode, useContext, useState } from "react";
|
||||||
|
|
||||||
|
export type MainModule = "peers" | "settings";
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
active: MainModule;
|
||||||
|
setActive: (m: MainModule) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MainModuleContext = createContext<Ctx | null>(null);
|
||||||
|
|
||||||
|
export const MainModuleProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [active, setActive] = useState<MainModule>("peers");
|
||||||
|
return (
|
||||||
|
<MainModuleContext.Provider value={{ active, setActive }}>
|
||||||
|
{children}
|
||||||
|
</MainModuleContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMainModule = () => {
|
||||||
|
const ctx = useContext(MainModuleContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useMainModule must be used within MainModuleProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
17
client/ui/frontend/src/lib/color.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import chroma from "chroma-js";
|
||||||
|
|
||||||
|
export const generateColorFromString = (str?: string) => {
|
||||||
|
if (!str) return "#f68330";
|
||||||
|
if (str.includes("System")) return "#808080";
|
||||||
|
if (str.toLowerCase().startsWith("netbird")) return "#f68330";
|
||||||
|
let hash = 0;
|
||||||
|
str.split("").forEach((char) => {
|
||||||
|
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||||
|
});
|
||||||
|
let colour = "#";
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const value = (hash >> (i * 8)) & 0xff;
|
||||||
|
colour += value.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return chroma(colour).saturate(2).luminance(0.4).hex();
|
||||||
|
};
|
||||||
23
client/ui/frontend/src/lib/welcome.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const ART = `
|
||||||
|
_ __ __ ____ _ __ ______ __ __ __
|
||||||
|
/ | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / /
|
||||||
|
/ |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ /
|
||||||
|
/ /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ /
|
||||||
|
/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function welcome() {
|
||||||
|
const message = `%c${ART}%c
|
||||||
|
NetBird — The Only Secure Access Platform You'll Ever Need.
|
||||||
|
|
||||||
|
WEBSITE: https://netbird.io/
|
||||||
|
WE'RE HIRING: https://careers.netbird.io/
|
||||||
|
OPEN SOURCE: https://github.com/netbirdio/netbird
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
message,
|
||||||
|
"color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;",
|
||||||
|
"color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "./App";
|
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { useStatus } from "@/hooks/useStatus";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Update as UpdateSvc } from "@bindings/services";
|
||||||
|
|
||||||
|
// TODO: Shown only when management has auto updates enabled + there are updates available + force updates is disabled
|
||||||
|
export const UpdateAvailableBanner = () => {
|
||||||
|
const { status } = useStatus();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) return null;
|
||||||
|
|
||||||
|
const updateVersion = (status?.events ?? [])
|
||||||
|
.map((e) => e.metadata?.["new_version_available"])
|
||||||
|
.find((v): v is string => Boolean(v));
|
||||||
|
|
||||||
|
if (!updateVersion || dismissed) return null;
|
||||||
|
|
||||||
|
const triggerUpdate = () => {
|
||||||
|
UpdateSvc.Trigger().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-4 left-1/2 -translate-x-1/2 z-50",
|
||||||
|
"w-[calc(100%-2rem)] max-w-xl",
|
||||||
|
"flex items-center justify-between gap-3",
|
||||||
|
"rounded-xl border border-nb-gray-800 bg-white backdrop-blur",
|
||||||
|
"px-2 py-2 shadow-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className={"text-sm text-nb-gray-900 pr-4 pl-2 font-medium"}>
|
||||||
|
NetBird will update when you restart the app.
|
||||||
|
</p>
|
||||||
|
<div className={"flex gap-2"}>
|
||||||
|
<Button variant={"subtle"} size={"xs"} onClick={() => setDismissed(true)}>
|
||||||
|
Later
|
||||||
|
</Button>
|
||||||
|
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||||
|
Restart now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateAvailableBanner;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createContext, type ReactNode } from "react";
|
||||||
|
import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts";
|
||||||
|
|
||||||
|
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
|
||||||
|
|
||||||
|
export const DebugBundleContext =
|
||||||
|
createContext<DebugBundleContextValue | null>(null);
|
||||||
|
|
||||||
|
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const value = useDebugBundle();
|
||||||
|
return (
|
||||||
|
<DebugBundleContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DebugBundleContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
190
client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Connection as ConnectionSvc,
|
||||||
|
Debug as DebugSvc,
|
||||||
|
} from "@bindings/services";
|
||||||
|
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||||
|
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||||
|
|
||||||
|
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||||
|
const TRACE_LOG_FILE_COUNT = 5;
|
||||||
|
const PLAIN_LOG_FILE_COUNT = 1;
|
||||||
|
|
||||||
|
export type DebugStage =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "preparing-trace" }
|
||||||
|
| { kind: "reconnecting" }
|
||||||
|
| { kind: "capturing"; remainingSec: number; totalSec: number }
|
||||||
|
| { kind: "restoring-level" }
|
||||||
|
| { kind: "bundling" }
|
||||||
|
| { kind: "uploading" }
|
||||||
|
| { kind: "cancelling" }
|
||||||
|
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
const sleep = (ms: number, signal: AbortSignal) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(new DOMException("aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(id);
|
||||||
|
reject(new DOMException("aborted", "AbortError"));
|
||||||
|
};
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
signal.addEventListener("abort", onAbort);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAbort = (e: unknown) =>
|
||||||
|
e instanceof DOMException && e.name === "AbortError";
|
||||||
|
|
||||||
|
export const useDebugBundle = () => {
|
||||||
|
const { activeProfile, username } = useProfile();
|
||||||
|
const [anonymize, setAnonymize] = useState(false);
|
||||||
|
const [systemInfo, setSystemInfo] = useState(true);
|
||||||
|
const [upload, setUpload] = useState(true);
|
||||||
|
const [trace, setTrace] = useState(true);
|
||||||
|
const [traceMinutes, setTraceMinutes] = useState(1);
|
||||||
|
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
|
||||||
|
const [lastBundlePath, setLastBundlePath] = useState<string>("");
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const isRunning =
|
||||||
|
stage.kind !== "idle" &&
|
||||||
|
stage.kind !== "done" &&
|
||||||
|
stage.kind !== "error";
|
||||||
|
|
||||||
|
const reset = () => setStage({ kind: "idle" });
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (!abortRef.current || abortRef.current.signal.aborted) return;
|
||||||
|
abortRef.current.abort();
|
||||||
|
setStage({ kind: "cancelling" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
const signal = ctrl.signal;
|
||||||
|
const checkAbort = () => {
|
||||||
|
if (signal.aborted)
|
||||||
|
throw new DOMException("aborted", "AbortError");
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||||
|
let originalLevel = "info";
|
||||||
|
let raisedLevel = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (trace) {
|
||||||
|
setStage({ kind: "preparing-trace" });
|
||||||
|
try {
|
||||||
|
const cur = await DebugSvc.GetLogLevel();
|
||||||
|
if (cur?.level) originalLevel = cur.level;
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
checkAbort();
|
||||||
|
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||||
|
raisedLevel = true;
|
||||||
|
|
||||||
|
checkAbort();
|
||||||
|
setStage({ kind: "reconnecting" });
|
||||||
|
try {
|
||||||
|
await ConnectionSvc.Down();
|
||||||
|
} catch {
|
||||||
|
// already down
|
||||||
|
}
|
||||||
|
checkAbort();
|
||||||
|
await ConnectionSvc.Up({
|
||||||
|
profileName: activeProfile,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSec =
|
||||||
|
Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||||
|
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||||
|
setStage({
|
||||||
|
kind: "capturing",
|
||||||
|
remainingSec: remaining,
|
||||||
|
totalSec,
|
||||||
|
});
|
||||||
|
await sleep(1000, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage({ kind: "restoring-level" });
|
||||||
|
try {
|
||||||
|
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||||
|
raisedLevel = false;
|
||||||
|
} catch {
|
||||||
|
// restore is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAbort();
|
||||||
|
setStage({ kind: "bundling" });
|
||||||
|
const logFileCount = trace
|
||||||
|
? TRACE_LOG_FILE_COUNT
|
||||||
|
: PLAIN_LOG_FILE_COUNT;
|
||||||
|
|
||||||
|
if (uploadUrl) setStage({ kind: "uploading" });
|
||||||
|
const result = await DebugSvc.Bundle({
|
||||||
|
anonymize,
|
||||||
|
systemInfo,
|
||||||
|
uploadUrl,
|
||||||
|
logFileCount,
|
||||||
|
});
|
||||||
|
checkAbort();
|
||||||
|
if (result.path) setLastBundlePath(result.path);
|
||||||
|
setStage({
|
||||||
|
kind: "done",
|
||||||
|
result,
|
||||||
|
uploadAttempted: Boolean(uploadUrl),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbort(e)) {
|
||||||
|
if (raisedLevel) {
|
||||||
|
try {
|
||||||
|
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStage({ kind: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStage({ kind: "error", message: String(e) });
|
||||||
|
} finally {
|
||||||
|
if (abortRef.current === ctrl) abortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBundleDir = () => {
|
||||||
|
if (!lastBundlePath) return;
|
||||||
|
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
anonymize,
|
||||||
|
setAnonymize,
|
||||||
|
systemInfo,
|
||||||
|
setSystemInfo,
|
||||||
|
upload,
|
||||||
|
setUpload,
|
||||||
|
trace,
|
||||||
|
setTrace,
|
||||||
|
traceMinutes,
|
||||||
|
setTraceMinutes,
|
||||||
|
stage,
|
||||||
|
isRunning,
|
||||||
|
lastBundlePath,
|
||||||
|
run,
|
||||||
|
cancel,
|
||||||
|
reset,
|
||||||
|
openBundleDir,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||||
|
|
||||||
|
export const useDebugBundleContext = () => {
|
||||||
|
const ctx = useContext(DebugBundleContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useDebugBundleContext must be used inside DebugBundleProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
53
client/ui/frontend/src/modules/peers/PeerFilters.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export type StatusFilter = "all" | "online" | "offline";
|
||||||
|
|
||||||
|
const FILTERS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "online", label: "Online" },
|
||||||
|
{ value: "offline", label: "Offline" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: StatusFilter;
|
||||||
|
onChange: (value: StatusFilter) => void;
|
||||||
|
counts: Record<StatusFilter, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeerFilters = ({ value, onChange, counts }: Props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex w-full rounded-md border border-nb-gray-900 bg-nb-gray-940 p-0.5"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{FILTERS.map((f) => {
|
||||||
|
const active = value === f.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
type={"button"}
|
||||||
|
onClick={() => onChange(f.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 inline-flex items-center justify-center gap-1.5 rounded px-2.5 py-2 text-xs font-medium",
|
||||||
|
"transition-colors duration-150 cursor-default outline-none",
|
||||||
|
active
|
||||||
|
? "bg-nb-gray-800 text-nb-gray-100"
|
||||||
|
: "text-nb-gray-400 hover:text-nb-gray-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[0.65rem] font-mono",
|
||||||
|
active ? "text-nb-gray-300" : "text-nb-gray-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{counts[f.value]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
client/ui/frontend/src/modules/peers/Peers.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { SearchInput } from "@/components/SearchInput";
|
||||||
|
import { mockPeers } from "./mockPeers";
|
||||||
|
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||||
|
import { PeersList } from "./PeersList";
|
||||||
|
|
||||||
|
const isOnline = (status: string) => status === "connected";
|
||||||
|
|
||||||
|
export const Peers = () => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
|
||||||
|
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
||||||
|
const online = mockPeers.filter((p) => isOnline(p.status)).length;
|
||||||
|
return {
|
||||||
|
all: mockPeers.length,
|
||||||
|
online,
|
||||||
|
offline: mockPeers.length - online,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return mockPeers.filter((p) => {
|
||||||
|
if (statusFilter === "online" && !isOnline(p.status)) return false;
|
||||||
|
if (statusFilter === "offline" && isOnline(p.status)) return false;
|
||||||
|
if (q && !p.fqdn.toLowerCase().includes(q) && !p.ip.includes(q)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [search, statusFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||||
|
<div className={"flex flex-col gap-3 px-4"}>
|
||||||
|
<SearchInput
|
||||||
|
placeholder={"Search by FQDN or IP…"}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<PeerFilters
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
counts={counts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea.Root
|
||||||
|
type={"auto"}
|
||||||
|
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||||
|
>
|
||||||
|
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||||
|
<PeersList data={filtered} />
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Scrollbar
|
||||||
|
orientation={"vertical"}
|
||||||
|
className={cn(
|
||||||
|
"flex select-none touch-none transition-colors",
|
||||||
|
"w-1.5 bg-transparent py-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ScrollArea.Thumb
|
||||||
|
className={
|
||||||
|
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollArea.Scrollbar>
|
||||||
|
</ScrollArea.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
client/ui/frontend/src/modules/peers/PeersList.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Peer, PeerStatus } from "./types";
|
||||||
|
|
||||||
|
const DOT: Record<PeerStatus, string> = {
|
||||||
|
connected: "bg-green-400",
|
||||||
|
connecting: "bg-yellow-300 animate-pulse-slow",
|
||||||
|
disconnected: "bg-nb-gray-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeersList = ({ data }: { data: Peer[] }) => {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
||||||
|
No peers match the current filters.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={"flex flex-col"}>
|
||||||
|
{data.map((peer) => (
|
||||||
|
<li
|
||||||
|
key={peer.id}
|
||||||
|
className={"flex items-center gap-3 px-4 py-3 min-w-0"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full shrink-0",
|
||||||
|
DOT[peer.status],
|
||||||
|
)}
|
||||||
|
title={peer.status}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{peer.fqdn}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"ml-auto text-xs font-mono text-nb-gray-400 shrink-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{peer.ip}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
385
client/ui/frontend/src/modules/peers/mockPeers.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { Peer } from "./types";
|
||||||
|
|
||||||
|
const minutesAgo = (m: number) => new Date(Date.now() - m * 60 * 1000);
|
||||||
|
|
||||||
|
export const mockPeers: Peer[] = [
|
||||||
|
{
|
||||||
|
id: "p-001",
|
||||||
|
fqdn: "alice-laptop.netbird.cloud",
|
||||||
|
ip: "100.64.0.12",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(1),
|
||||||
|
latencyMs: 18,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 84,
|
||||||
|
bytesTx: 1024 * 1024 * 12,
|
||||||
|
endpointLocal: "192.168.1.24:51820",
|
||||||
|
endpointRemote: "203.0.113.45:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-002",
|
||||||
|
fqdn: "bob-desktop.netbird.cloud",
|
||||||
|
ip: "100.64.0.21",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(3),
|
||||||
|
latencyMs: 42,
|
||||||
|
relayed: true,
|
||||||
|
relayAddress: "rel.eu-central.netbird.io:443",
|
||||||
|
iceLocalCandidate: "relay",
|
||||||
|
iceRemoteCandidate: "relay",
|
||||||
|
bytesRx: 1024 * 380,
|
||||||
|
bytesTx: 1024 * 940,
|
||||||
|
endpointLocal: "10.0.0.8:51820",
|
||||||
|
endpointRemote: "198.51.100.7:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-003",
|
||||||
|
fqdn: "build-runner-01.netbird.cloud",
|
||||||
|
ip: "100.64.0.34",
|
||||||
|
status: "connecting",
|
||||||
|
lastHandshake: minutesAgo(15),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 0,
|
||||||
|
bytesTx: 0,
|
||||||
|
endpointLocal: "192.168.1.45:51820",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-004",
|
||||||
|
fqdn: "carol-phone.netbird.cloud",
|
||||||
|
ip: "100.64.0.55",
|
||||||
|
status: "disconnected",
|
||||||
|
lastHandshake: minutesAgo(620),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 5,
|
||||||
|
bytesTx: 1024 * 1024 * 2,
|
||||||
|
endpointLocal: "—",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-005",
|
||||||
|
fqdn: "exit-berlin.netbird.cloud",
|
||||||
|
ip: "100.64.0.2",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.2),
|
||||||
|
latencyMs: 9,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 1024 * 2,
|
||||||
|
bytesTx: 1024 * 1024 * 512,
|
||||||
|
endpointLocal: "10.10.0.4:51820",
|
||||||
|
endpointRemote: "203.0.113.99:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-006",
|
||||||
|
fqdn: "db-replica-eu.netbird.cloud",
|
||||||
|
ip: "100.64.0.78",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(7),
|
||||||
|
latencyMs: 64,
|
||||||
|
relayed: true,
|
||||||
|
relayAddress: "rel.us-east.netbird.io:443",
|
||||||
|
iceLocalCandidate: "relay",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 240,
|
||||||
|
bytesTx: 1024 * 1024 * 90,
|
||||||
|
endpointLocal: "172.16.0.10:51820",
|
||||||
|
endpointRemote: "198.51.100.42:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-007",
|
||||||
|
fqdn: "dev-vm-mac.netbird.cloud",
|
||||||
|
ip: "100.64.0.91",
|
||||||
|
status: "disconnected",
|
||||||
|
lastHandshake: minutesAgo(2880),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 0,
|
||||||
|
bytesTx: 0,
|
||||||
|
endpointLocal: "—",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-008",
|
||||||
|
fqdn: "ci-worker-03.netbird.cloud",
|
||||||
|
ip: "100.64.0.103",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.5),
|
||||||
|
latencyMs: 27,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "prflx",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 14,
|
||||||
|
bytesTx: 1024 * 1024 * 3,
|
||||||
|
endpointLocal: "192.168.50.7:51820",
|
||||||
|
endpointRemote: "203.0.113.61:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-009",
|
||||||
|
fqdn: "k8s-control-plane.netbird.cloud",
|
||||||
|
ip: "100.64.0.110",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(2),
|
||||||
|
latencyMs: 12,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 410,
|
||||||
|
bytesTx: 1024 * 1024 * 380,
|
||||||
|
endpointLocal: "10.0.1.10:51820",
|
||||||
|
endpointRemote: "10.0.1.11:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-010",
|
||||||
|
fqdn: "k8s-worker-01.netbird.cloud",
|
||||||
|
ip: "100.64.0.111",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(2),
|
||||||
|
latencyMs: 14,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 220,
|
||||||
|
bytesTx: 1024 * 1024 * 190,
|
||||||
|
endpointLocal: "10.0.1.20:51820",
|
||||||
|
endpointRemote: "10.0.1.21:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-011",
|
||||||
|
fqdn: "k8s-worker-02.netbird.cloud",
|
||||||
|
ip: "100.64.0.112",
|
||||||
|
status: "connecting",
|
||||||
|
lastHandshake: minutesAgo(8),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 0,
|
||||||
|
bytesTx: 0,
|
||||||
|
endpointLocal: "10.0.1.22:51820",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-012",
|
||||||
|
fqdn: "monitoring-prom.netbird.cloud",
|
||||||
|
ip: "100.64.0.130",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.3),
|
||||||
|
latencyMs: 22,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 56,
|
||||||
|
bytesTx: 1024 * 1024 * 18,
|
||||||
|
endpointLocal: "10.20.0.5:51820",
|
||||||
|
endpointRemote: "203.0.113.122:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-013",
|
||||||
|
fqdn: "grafana.netbird.cloud",
|
||||||
|
ip: "100.64.0.131",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.4),
|
||||||
|
latencyMs: 19,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 32,
|
||||||
|
bytesTx: 1024 * 1024 * 8,
|
||||||
|
endpointLocal: "10.20.0.6:51820",
|
||||||
|
endpointRemote: "203.0.113.123:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-014",
|
||||||
|
fqdn: "loki-log-aggregator.netbird.cloud",
|
||||||
|
ip: "100.64.0.132",
|
||||||
|
status: "disconnected",
|
||||||
|
lastHandshake: minutesAgo(45),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 12,
|
||||||
|
bytesTx: 1024 * 1024 * 4,
|
||||||
|
endpointLocal: "—",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-015",
|
||||||
|
fqdn: "dave-laptop.netbird.cloud",
|
||||||
|
ip: "100.64.0.140",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(1),
|
||||||
|
latencyMs: 38,
|
||||||
|
relayed: true,
|
||||||
|
relayAddress: "rel.eu-west.netbird.io:443",
|
||||||
|
iceLocalCandidate: "relay",
|
||||||
|
iceRemoteCandidate: "relay",
|
||||||
|
bytesRx: 1024 * 720,
|
||||||
|
bytesTx: 1024 * 410,
|
||||||
|
endpointLocal: "192.168.43.21:51820",
|
||||||
|
endpointRemote: "198.51.100.88:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-016",
|
||||||
|
fqdn: "eve-iphone.netbird.cloud",
|
||||||
|
ip: "100.64.0.150",
|
||||||
|
status: "connecting",
|
||||||
|
lastHandshake: minutesAgo(20),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 0,
|
||||||
|
bytesTx: 0,
|
||||||
|
endpointLocal: "—",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-017",
|
||||||
|
fqdn: "frank-windows.netbird.cloud",
|
||||||
|
ip: "100.64.0.155",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(4),
|
||||||
|
latencyMs: 76,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 6,
|
||||||
|
bytesTx: 1024 * 1024 * 2,
|
||||||
|
endpointLocal: "192.168.1.55:51820",
|
||||||
|
endpointRemote: "203.0.113.200:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-018",
|
||||||
|
fqdn: "exit-frankfurt.netbird.cloud",
|
||||||
|
ip: "100.64.0.3",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.1),
|
||||||
|
latencyMs: 6,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 1024 * 5,
|
||||||
|
bytesTx: 1024 * 1024 * 1024 * 1,
|
||||||
|
endpointLocal: "10.10.0.5:51820",
|
||||||
|
endpointRemote: "203.0.113.150:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-019",
|
||||||
|
fqdn: "exit-singapore.netbird.cloud",
|
||||||
|
ip: "100.64.0.4",
|
||||||
|
status: "disconnected",
|
||||||
|
lastHandshake: minutesAgo(180),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 880,
|
||||||
|
bytesTx: 1024 * 1024 * 220,
|
||||||
|
endpointLocal: "—",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-020",
|
||||||
|
fqdn: "nas-home.netbird.cloud",
|
||||||
|
ip: "100.64.0.180",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.7),
|
||||||
|
latencyMs: 31,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 1024 * 3,
|
||||||
|
bytesTx: 1024 * 1024 * 480,
|
||||||
|
endpointLocal: "192.168.0.50:51820",
|
||||||
|
endpointRemote: "203.0.113.45:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-021",
|
||||||
|
fqdn: "raspberrypi-iot.netbird.cloud",
|
||||||
|
ip: "100.64.0.181",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(5),
|
||||||
|
latencyMs: 54,
|
||||||
|
relayed: true,
|
||||||
|
relayAddress: "rel.eu-central.netbird.io:443",
|
||||||
|
iceLocalCandidate: "relay",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 240,
|
||||||
|
bytesTx: 1024 * 110,
|
||||||
|
endpointLocal: "192.168.0.121:51820",
|
||||||
|
endpointRemote: "198.51.100.42:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-022",
|
||||||
|
fqdn: "staging-api.netbird.cloud",
|
||||||
|
ip: "100.64.0.200",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.2),
|
||||||
|
latencyMs: 16,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 92,
|
||||||
|
bytesTx: 1024 * 1024 * 140,
|
||||||
|
endpointLocal: "10.30.0.10:51820",
|
||||||
|
endpointRemote: "10.30.0.11:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-023",
|
||||||
|
fqdn: "prod-api-eu.netbird.cloud",
|
||||||
|
ip: "100.64.0.201",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.1),
|
||||||
|
latencyMs: 8,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 1024 * 1024 * 1024 * 12,
|
||||||
|
bytesTx: 1024 * 1024 * 1024 * 3,
|
||||||
|
endpointLocal: "10.40.0.10:51820",
|
||||||
|
endpointRemote: "10.40.0.11:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-024",
|
||||||
|
fqdn: "prod-api-us.netbird.cloud",
|
||||||
|
ip: "100.64.0.202",
|
||||||
|
status: "connected",
|
||||||
|
lastHandshake: minutesAgo(0.1),
|
||||||
|
latencyMs: 92,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "srflx",
|
||||||
|
iceRemoteCandidate: "srflx",
|
||||||
|
bytesRx: 1024 * 1024 * 1024 * 8,
|
||||||
|
bytesTx: 1024 * 1024 * 1024 * 2,
|
||||||
|
endpointLocal: "10.50.0.10:51820",
|
||||||
|
endpointRemote: "203.0.113.210:51820",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p-025",
|
||||||
|
fqdn: "old-jenkins.netbird.cloud",
|
||||||
|
ip: "100.64.0.220",
|
||||||
|
status: "disconnected",
|
||||||
|
lastHandshake: minutesAgo(8640),
|
||||||
|
latencyMs: 0,
|
||||||
|
relayed: false,
|
||||||
|
iceLocalCandidate: "host",
|
||||||
|
iceRemoteCandidate: "host",
|
||||||
|
bytesRx: 0,
|
||||||
|
bytesTx: 0,
|
||||||
|
endpointLocal: "—",
|
||||||
|
endpointRemote: "—",
|
||||||
|
},
|
||||||
|
];
|
||||||
20
client/ui/frontend/src/modules/peers/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export type PeerStatus = "connected" | "connecting" | "disconnected";
|
||||||
|
|
||||||
|
export type IceCandidateType = "host" | "srflx" | "relay" | "prflx";
|
||||||
|
|
||||||
|
export type Peer = {
|
||||||
|
id: string;
|
||||||
|
fqdn: string;
|
||||||
|
ip: string;
|
||||||
|
status: PeerStatus;
|
||||||
|
lastHandshake: Date;
|
||||||
|
latencyMs: number;
|
||||||
|
relayed: boolean;
|
||||||
|
relayAddress?: string;
|
||||||
|
iceLocalCandidate: IceCandidateType;
|
||||||
|
iceRemoteCandidate: IceCandidateType;
|
||||||
|
bytesRx: number;
|
||||||
|
bytesTx: number;
|
||||||
|
endpointLocal: string;
|
||||||
|
endpointRemote: string;
|
||||||
|
};
|
||||||
76
client/ui/frontend/src/modules/profile/ProfileContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { Profiles as ProfilesSvc } from "@bindings/services";
|
||||||
|
|
||||||
|
type ProfileContextValue = {
|
||||||
|
username: string;
|
||||||
|
activeProfile: string;
|
||||||
|
loaded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
switchProfile: (name: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useProfile = () => {
|
||||||
|
const ctx = useContext(ProfileContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useProfile must be used inside ProfileProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [activeProfile, setActiveProfile] = useState("");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const u = await ProfilesSvc.Username();
|
||||||
|
const active = await ProfilesSvc.GetActive();
|
||||||
|
setUsername(u);
|
||||||
|
setActiveProfile(active.profileName || "default");
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const switchProfile = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
await ProfilesSvc.Switch({ profileName: name, username });
|
||||||
|
setActiveProfile(name);
|
||||||
|
},
|
||||||
|
[username],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileContext.Provider
|
||||||
|
value={{
|
||||||
|
username,
|
||||||
|
activeProfile,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
switchProfile,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ProfileContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||||
|
import { SwitchItem } from "@/components/SwitchItem";
|
||||||
|
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||||
|
import { ManagementMode } from "@/modules/settings/useManagementUrl.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: ManagementMode;
|
||||||
|
onChange: (mode: ManagementMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||||
|
return (
|
||||||
|
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
|
||||||
|
<SwitchItem value={ManagementMode.Cloud}>
|
||||||
|
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||||
|
Cloud
|
||||||
|
</SwitchItem>
|
||||||
|
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
|
||||||
|
</SwitchItemGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
client/ui/frontend/src/modules/settings/Settings.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||||
|
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||||
|
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
|
||||||
|
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||||
|
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||||
|
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||||
|
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||||
|
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||||
|
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||||
|
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||||
|
|
||||||
|
export const Settings = () => {
|
||||||
|
const [active, setActive] = useState("general");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||||
|
<SettingsNavigationTriggers />
|
||||||
|
<MainRightSide>
|
||||||
|
<ScrollArea.Root
|
||||||
|
type={"auto"}
|
||||||
|
className={"flex-1 min-h-0 overflow-hidden"}
|
||||||
|
>
|
||||||
|
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||||
|
<div className={"py-8 px-7"}>
|
||||||
|
<SettingsProvider>
|
||||||
|
<VerticalTabs.Content value={"general"}>
|
||||||
|
<SettingsGeneral />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"network"}>
|
||||||
|
<SettingsNetwork />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"security"}>
|
||||||
|
<SettingsSecurity />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"ssh"}>
|
||||||
|
<SettingsSSH />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"advanced"}>
|
||||||
|
<SettingsAdvanced />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"troubleshooting"}>
|
||||||
|
<SettingsTroubleshooting />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"about"}>
|
||||||
|
<SettingsAbout />
|
||||||
|
</VerticalTabs.Content>
|
||||||
|
</SettingsProvider>
|
||||||
|
</div>
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Scrollbar
|
||||||
|
orientation={"vertical"}
|
||||||
|
className={cn(
|
||||||
|
"flex select-none touch-none transition-colors",
|
||||||
|
"w-1.5 bg-transparent py-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ScrollArea.Thumb
|
||||||
|
className={
|
||||||
|
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollArea.Scrollbar>
|
||||||
|
</ScrollArea.Root>
|
||||||
|
</MainRightSide>
|
||||||
|
</VerticalTabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
client/ui/frontend/src/modules/settings/SettingsAbout.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Browser } from "@wailsio/runtime";
|
||||||
|
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||||
|
import pkg from "../../../package.json";
|
||||||
|
import { useStatus } from "@/hooks/useStatus";
|
||||||
|
import { NetBirdVersionCard } from "@/components/NetBirdVersionCard";
|
||||||
|
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||||
|
|
||||||
|
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||||
|
{ label: "Imprint", url: "https://netbird.io/imprint" },
|
||||||
|
{ label: "Privacy", url: "https://netbird.io/privacy" },
|
||||||
|
{ label: "CLA", url: "https://netbird.io/cla" },
|
||||||
|
{ label: "Terms of Service", url: "https://netbird.io/terms" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function openUrl(url: string) {
|
||||||
|
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsAbout() {
|
||||||
|
const { status } = useStatus();
|
||||||
|
const guiVersion = pkg.version;
|
||||||
|
const daemonVersion = status?.daemonVersion ?? "—";
|
||||||
|
|
||||||
|
const handleVersionClick = useAccentTrigger();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||||
|
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||||
|
<p
|
||||||
|
className={"text-sm font-semibold text-nb-gray-100 cursor-default select-none"}
|
||||||
|
onClick={handleVersionClick}
|
||||||
|
>
|
||||||
|
NetBird Client v{daemonVersion}
|
||||||
|
</p>
|
||||||
|
<p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NetBirdVersionCard />
|
||||||
|
|
||||||
|
<p className={"text-sm text-nb-gray-300 text-center"}>
|
||||||
|
© {new Date().getFullYear()} NetBird. All Rights Reserved.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
|
||||||
|
>
|
||||||
|
{LEGAL_LINKS.map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.url}
|
||||||
|
type={"button"}
|
||||||
|
onClick={() => openUrl(link.url)}
|
||||||
|
className={
|
||||||
|
"decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
client/ui/frontend/src/modules/settings/SettingsAccent.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
export function useAccentTrigger() {
|
||||||
|
const clicksRef = useRef(0);
|
||||||
|
const lastClickRef = useRef(0);
|
||||||
|
|
||||||
|
return useCallback(() => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastClickRef.current > 400) {
|
||||||
|
clicksRef.current = 0;
|
||||||
|
}
|
||||||
|
lastClickRef.current = now;
|
||||||
|
clicksRef.current += 1;
|
||||||
|
if (clicksRef.current >= 10) {
|
||||||
|
clicksRef.current = 0;
|
||||||
|
triggerAccent();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerAccent() {
|
||||||
|
if (document.getElementById("nb-accent-root")) return;
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = "nb-accent-root";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
root.unmount();
|
||||||
|
container.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
root.render(<Accent onDone={cleanup} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Accent({ onDone }: { onDone: () => void }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const raf = requestAnimationFrame(() => setVisible(true));
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth * dpr;
|
||||||
|
canvas.height = window.innerHeight * dpr;
|
||||||
|
canvas.style.width = `${window.innerWidth}px`;
|
||||||
|
canvas.style.height = `${window.innerHeight}px`;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
const chars = "TEAMNETBIRD";
|
||||||
|
const fontSize = 16;
|
||||||
|
const columns = Math.floor(window.innerWidth / fontSize);
|
||||||
|
const drops = Array.from({ length: columns }, () => Math.random() * -50);
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let last = 0;
|
||||||
|
const draw = (t: number) => {
|
||||||
|
if (t - last > 50) {
|
||||||
|
last = t;
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
|
||||||
|
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.font = `${fontSize}px ui-monospace, monospace`;
|
||||||
|
ctx.fillStyle = "#f68330";
|
||||||
|
|
||||||
|
for (let i = 0; i < drops.length; i++) {
|
||||||
|
const ch = chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
const y = drops[i] * fontSize;
|
||||||
|
ctx.fillText(ch, i * fontSize, y);
|
||||||
|
if (y > window.innerHeight && Math.random() > 0.975) {
|
||||||
|
drops[i] = 0;
|
||||||
|
}
|
||||||
|
drops[i]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
window.setTimeout(onDone, 500);
|
||||||
|
}, 9000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}, [onDone]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-50 bg-black/5 transition-opacity duration-500 pointer-events-none ${visible ? "opacity-100" : "opacity-0"}`}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className={"block"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { HelpText } from "@/components/HelpText";
|
||||||
|
import { Input } from "@/components/Input";
|
||||||
|
import { Label } from "@/components/Label";
|
||||||
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
|
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
|
||||||
|
export function SettingsAdvanced() {
|
||||||
|
const { config, saveFields } = useSettings();
|
||||||
|
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
interfaceName: config.interfaceName,
|
||||||
|
wireguardPort: config.wireguardPort,
|
||||||
|
mtu: config.mtu,
|
||||||
|
preSharedKey: config.preSharedKey,
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
values.interfaceName !== config.interfaceName ||
|
||||||
|
values.wireguardPort !== config.wireguardPort ||
|
||||||
|
values.mtu !== config.mtu ||
|
||||||
|
values.preSharedKey !== config.preSharedKey;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!hasChanges || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await saveFields(values);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionGroup title={"Interface"}>
|
||||||
|
<Input
|
||||||
|
label={"Name"}
|
||||||
|
value={values.interfaceName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, interfaceName: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={"grid grid-cols-2 gap-4"}>
|
||||||
|
<Input
|
||||||
|
label={"Port"}
|
||||||
|
type={"number"}
|
||||||
|
value={values.wireguardPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({
|
||||||
|
...v,
|
||||||
|
wireguardPort: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={"MTU"}
|
||||||
|
type={"number"}
|
||||||
|
value={values.mtu}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<SectionGroup title={"Security"}>
|
||||||
|
<div>
|
||||||
|
<Label as={"div"}>Pre-shared Key</Label>
|
||||||
|
<HelpText>
|
||||||
|
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
|
||||||
|
NetBird Setup Key. You will only communicate with peers that use the same
|
||||||
|
pre-shared key.
|
||||||
|
</HelpText>
|
||||||
|
<Input
|
||||||
|
type={"password"}
|
||||||
|
showPasswordToggle
|
||||||
|
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||||
|
value={values.preSharedKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||||
|
<div className={"w-full flex justify-end px-8 py-5 border-t border-nb-gray-910"}>
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
size={"md"}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
client/ui/frontend/src/modules/settings/SettingsContext.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { Settings as SettingsSvc } from "@bindings/services";
|
||||||
|
import type { Config } from "@bindings/services/models.js";
|
||||||
|
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||||
|
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||||
|
|
||||||
|
const SAVE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
type SettingsContextValue = {
|
||||||
|
config: Config;
|
||||||
|
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||||
|
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||||
|
saveFields: (partial: Partial<Config>) => Promise<void>;
|
||||||
|
saveNow: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const ctx = useContext(SettingsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useSettings must be used inside SettingsProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSettingsState = () => {
|
||||||
|
const { username, activeProfile, loaded: profileLoaded } = useProfile();
|
||||||
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profileLoaded || !activeProfile) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const c = await SettingsSvc.GetConfig({
|
||||||
|
profileName: activeProfile,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
setConfig(c);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [profileLoaded, activeProfile, username]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (next: Config) => {
|
||||||
|
// The daemon masks an existing PSK as "**********" in GetConfig.
|
||||||
|
// Sending the mask back round-trips it into the saved config and
|
||||||
|
// wgtypes.ParseKey fails on the next connect. Drop the mask so
|
||||||
|
// unrelated toggles don't corrupt the stored PSK.
|
||||||
|
const { preSharedKey, ...rest } = next;
|
||||||
|
try {
|
||||||
|
await SettingsSvc.SetConfig({
|
||||||
|
...rest,
|
||||||
|
...(preSharedKey === "**********" ? {} : { preSharedKey }),
|
||||||
|
profileName: activeProfile,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeProfile, username],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setField = useCallback(
|
||||||
|
<K extends keyof Config>(k: K, v: Config[K]) => {
|
||||||
|
setConfig((c) => {
|
||||||
|
if (!c) return c;
|
||||||
|
const next = { ...c, [k]: v };
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
void save(next);
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveNow = useCallback(async () => {
|
||||||
|
if (!config) return;
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
}
|
||||||
|
await save(config);
|
||||||
|
}, [config, save]);
|
||||||
|
|
||||||
|
const saveField = useCallback(
|
||||||
|
async <K extends keyof Config>(k: K, v: Config[K]) => {
|
||||||
|
if (!config) return;
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
}
|
||||||
|
const next = { ...config, [k]: v };
|
||||||
|
setConfig(next);
|
||||||
|
await save(next);
|
||||||
|
},
|
||||||
|
[config, save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveFields = useCallback(
|
||||||
|
async (partial: Partial<Config>) => {
|
||||||
|
if (!config) return;
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
}
|
||||||
|
const next = { ...config, ...partial };
|
||||||
|
setConfig(next);
|
||||||
|
await save(next);
|
||||||
|
},
|
||||||
|
[config, save],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { config, error, setField, saveField, saveFields, saveNow };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { config, error, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||||
|
|
||||||
|
// TODO: Better displaying of errors
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
|
||||||
|
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||||
|
{!config ? (
|
||||||
|
<SkeletonSettings />
|
||||||
|
) : (
|
||||||
|
<SettingsContext.Provider
|
||||||
|
value={{
|
||||||
|
config,
|
||||||
|
setField,
|
||||||
|
saveField,
|
||||||
|
saveFields,
|
||||||
|
saveNow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
84
client/ui/frontend/src/modules/settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||||
|
import { HelpText } from "@/components/HelpText";
|
||||||
|
import { Input } from "@/components/Input";
|
||||||
|
import { Label } from "@/components/Label";
|
||||||
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
|
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
|
||||||
|
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
|
||||||
|
|
||||||
|
export function SettingsGeneral() {
|
||||||
|
const { config, setField } = useSettings();
|
||||||
|
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const prevMode = useRef(mode);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
prevMode.current === ManagementMode.Cloud &&
|
||||||
|
mode === ManagementMode.SelfHosted
|
||||||
|
) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
prevMode.current = mode;
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionGroup title={"General"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={!config.disableAutoConnect}
|
||||||
|
onChange={(v) => setField("disableAutoConnect", !v)}
|
||||||
|
label={"Connect on Startup"}
|
||||||
|
helpText={"Automatically establish a connection when the service starts."}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={!config.disableNotifications}
|
||||||
|
onChange={(v) => setField("disableNotifications", !v)}
|
||||||
|
label={"Desktop Notifications"}
|
||||||
|
helpText={"Show desktop notifications for new updates and connection events."}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<SectionGroup title={"Connection"}>
|
||||||
|
<div>
|
||||||
|
<div className={"flex items-start gap-3"}>
|
||||||
|
<div className={"flex-1 min-w-0"}>
|
||||||
|
<Label as={"div"}>Management Server</Label>
|
||||||
|
<HelpText>
|
||||||
|
Connect to NetBird Cloud or your own self-hosted management server.
|
||||||
|
Changes will reconnect the client.
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
{mode === ManagementMode.SelfHosted && (
|
||||||
|
<div className={"flex items-start gap-3 mt-2"}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={displayUrl}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder={"https://netbird.selfhosted.com:443"}
|
||||||
|
error={
|
||||||
|
showError
|
||||||
|
? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={"primary"}
|
||||||
|
size={"md"}
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={() => save()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||||
|
import {
|
||||||
|
BoltIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LifeBuoyIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
SquareTerminalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const SettingsNavigationTriggers = () => {
|
||||||
|
return (
|
||||||
|
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
|
||||||
|
<VerticalTabs.List>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"general"}
|
||||||
|
icon={SlidersHorizontalIcon}
|
||||||
|
title={"General"}
|
||||||
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"network"}
|
||||||
|
icon={NetworkIcon}
|
||||||
|
title={"Network"}
|
||||||
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"security"}
|
||||||
|
icon={ShieldIcon}
|
||||||
|
title={"Security"}
|
||||||
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"ssh"}
|
||||||
|
icon={SquareTerminalIcon}
|
||||||
|
title={"SSH"}
|
||||||
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"advanced"}
|
||||||
|
icon={BoltIcon}
|
||||||
|
title={"Advanced"}
|
||||||
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"troubleshooting"}
|
||||||
|
icon={LifeBuoyIcon}
|
||||||
|
title={"Troubleshooting"}
|
||||||
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"about"}
|
||||||
|
icon={InfoIcon}
|
||||||
|
title={"About"}
|
||||||
|
/>
|
||||||
|
</VerticalTabs.List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
client/ui/frontend/src/modules/settings/SettingsNetwork.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||||
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
|
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
|
||||||
|
export function SettingsNetwork() {
|
||||||
|
const { config, setField } = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionGroup title={"Connectivity"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.lazyConnectionEnabled}
|
||||||
|
onChange={(v) => setField("lazyConnectionEnabled", v)}
|
||||||
|
label={"Lazy Connections"}
|
||||||
|
helpText={
|
||||||
|
"Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.networkMonitor}
|
||||||
|
onChange={(v) => setField("networkMonitor", v)}
|
||||||
|
label={"Reconnect on Network Change"}
|
||||||
|
helpText={
|
||||||
|
"Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<SectionGroup title={"Routing & DNS"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={!config.disableDns}
|
||||||
|
onChange={(v) => setField("disableDns", !v)}
|
||||||
|
label={"Enable DNS"}
|
||||||
|
helpText={"Apply NetBird-managed DNS settings to the host resolver."}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={!config.disableClientRoutes}
|
||||||
|
onChange={(v) => setField("disableClientRoutes", !v)}
|
||||||
|
label={"Enable Client Routes"}
|
||||||
|
helpText={"Accept routes from other peers to reach their networks."}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={!config.disableServerRoutes}
|
||||||
|
onChange={(v) => setField("disableServerRoutes", !v)}
|
||||||
|
label={"Enable Server Routes"}
|
||||||
|
helpText={"Advertise this host's local routes to other peers."}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
client/ui/frontend/src/modules/settings/SettingsSSH.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||||
|
import { HelpText } from "@/components/HelpText";
|
||||||
|
import { Input } from "@/components/Input";
|
||||||
|
import { Label } from "@/components/Label";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
|
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
import { type ChangeEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function SettingsSSH() {
|
||||||
|
const { config, setField } = useSettings();
|
||||||
|
const isSSHServerEnabled = config.serverSshAllowed;
|
||||||
|
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||||
|
|
||||||
|
// Keep the local input in sync when the config changes from elsewhere
|
||||||
|
useEffect(() => {
|
||||||
|
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||||
|
}, [config.sshJwtCacheTtl]);
|
||||||
|
|
||||||
|
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setJwtTtlInput(v);
|
||||||
|
if (v === "") return;
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n) && n >= 0) {
|
||||||
|
setField("sshJwtCacheTtl", n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJwtTtlBlur = () => {
|
||||||
|
if (jwtTtlInput === "") {
|
||||||
|
setJwtTtlInput("0");
|
||||||
|
setField("sshJwtCacheTtl", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(jwtTtlInput);
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionGroup title={"Server"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.serverSshAllowed}
|
||||||
|
onChange={(v) => setField("serverSshAllowed", v)}
|
||||||
|
label={"Enable SSH Server"}
|
||||||
|
helpText={
|
||||||
|
"Run the NetBird SSH server on this host so other peers can connect to it."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.enableSshRoot}
|
||||||
|
onChange={(v) => setField("enableSshRoot", v)}
|
||||||
|
label={"Allow Root Login"}
|
||||||
|
helpText={
|
||||||
|
"Let peers sign in as the root user. Disable to require a non-privileged account."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.enableSshSftp}
|
||||||
|
onChange={(v) => setField("enableSshSftp", v)}
|
||||||
|
label={"Allow SFTP"}
|
||||||
|
helpText={"Transfer files securely using native SFTP or SCP clients."}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.enableSshLocalPortForwarding}
|
||||||
|
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||||
|
label={"Local Port Forwarding"}
|
||||||
|
helpText={
|
||||||
|
"Let connecting peers tunnel local ports to services reachable from this host."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.enableSshRemotePortForwarding}
|
||||||
|
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||||
|
label={"Remote Port Forwarding"}
|
||||||
|
helpText={
|
||||||
|
"Let connecting peers expose ports on this host back to their own machine."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={!config.disableSshAuth}
|
||||||
|
onChange={(v) => setField("disableSshAuth", !v)}
|
||||||
|
label={"Enable JWT Authentication"}
|
||||||
|
helpText={
|
||||||
|
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-6 justify-between",
|
||||||
|
config.disableSshAuth && "opacity-50 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={"flex-1 max-w-md"}>
|
||||||
|
<Label as={"div"}>JWT Cache TTL</Label>
|
||||||
|
<HelpText margin={false}>
|
||||||
|
How long this client caches a JWT before prompting again on outgoing SSH
|
||||||
|
connections. Set to 0 to disable caching and authenticate on every
|
||||||
|
connection.
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<div className={"w-40 shrink-0"}>
|
||||||
|
<Input
|
||||||
|
type={"number"}
|
||||||
|
min={0}
|
||||||
|
value={jwtTtlInput}
|
||||||
|
onChange={handleJwtTtlChange}
|
||||||
|
onBlur={handleJwtTtlBlur}
|
||||||
|
customSuffix={"Second(s)"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
client/ui/frontend/src/modules/settings/SettingsSection.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export const SectionGroup = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => (
|
||||||
|
<section className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}>
|
||||||
|
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className={"flex flex-col gap-5"}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
52
client/ui/frontend/src/modules/settings/SettingsSecurity.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||||
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
|
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
|
||||||
|
export function SettingsSecurity() {
|
||||||
|
const { config, setField } = useSettings();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionGroup title={"Firewall"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.blockInbound}
|
||||||
|
onChange={(v) => setField("blockInbound", v)}
|
||||||
|
label={"Block Inbound Traffic"}
|
||||||
|
helpText={
|
||||||
|
"Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.blockLanAccess}
|
||||||
|
onChange={(v) => setField("blockLanAccess", v)}
|
||||||
|
label={"Block LAN Access"}
|
||||||
|
helpText={
|
||||||
|
"Prevent peers from reaching your local network or its devices when this device routes their traffic."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
|
||||||
|
<SectionGroup title={"Encryption"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.rosenpassEnabled}
|
||||||
|
onChange={(v) => {
|
||||||
|
setField("rosenpassEnabled", v);
|
||||||
|
if (!v) setField("rosenpassPermissive", false);
|
||||||
|
}}
|
||||||
|
label={"Enable Quantum-Resistance"}
|
||||||
|
helpText={
|
||||||
|
"Add a post-quantum key exchange via Rosenpass on top of WireGuard®."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={config.rosenpassPermissive}
|
||||||
|
onChange={(v) => setField("rosenpassPermissive", v)}
|
||||||
|
label={"Enable Permissive Mode"}
|
||||||
|
helpText={
|
||||||
|
"Allow connections to peers without quantum-resistance support."
|
||||||
|
}
|
||||||
|
disabled={!config.rosenpassEnabled}
|
||||||
|
/>
|
||||||
|
</SectionGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
|
import { Debug as DebugSvc } from "@bindings/services";
|
||||||
|
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||||
|
import HelpText from "@/components/HelpText.tsx";
|
||||||
|
import { Input } from "@/components/Input";
|
||||||
|
import { Label } from "@/components/Label";
|
||||||
|
import { StatusPanel } from "@/components/StatusPanel";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts";
|
||||||
|
import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts";
|
||||||
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
|
|
||||||
|
export function SettingsTroubleshooting() {
|
||||||
|
const {
|
||||||
|
anonymize,
|
||||||
|
setAnonymize,
|
||||||
|
systemInfo,
|
||||||
|
setSystemInfo,
|
||||||
|
upload,
|
||||||
|
setUpload,
|
||||||
|
trace,
|
||||||
|
setTrace,
|
||||||
|
traceMinutes,
|
||||||
|
setTraceMinutes,
|
||||||
|
run,
|
||||||
|
stage,
|
||||||
|
cancel,
|
||||||
|
reset,
|
||||||
|
} = useDebugBundleContext();
|
||||||
|
|
||||||
|
if (stage.kind === "done" || stage.kind === "error") {
|
||||||
|
return <ResultSection stage={stage} onClose={reset} />;
|
||||||
|
}
|
||||||
|
if (stage.kind !== "idle") {
|
||||||
|
return <ProgressSection stage={stage} onCancel={cancel} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionGroup title={"Debug bundle"}>
|
||||||
|
<HelpText className={"-mt-2 mb-2"}>
|
||||||
|
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
|
||||||
|
.zip file with logs, system details and debug information from your device.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={anonymize}
|
||||||
|
onChange={setAnonymize}
|
||||||
|
label={"Anonymize Sensitive Information"}
|
||||||
|
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={systemInfo}
|
||||||
|
onChange={setSystemInfo}
|
||||||
|
label={"Include System Information"}
|
||||||
|
helpText={"Include OS, kernel, network interfaces, and routing tables."}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={upload}
|
||||||
|
onChange={setUpload}
|
||||||
|
label={"Upload Bundle to NetBird Servers"}
|
||||||
|
helpText={
|
||||||
|
"Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={trace}
|
||||||
|
onChange={setTrace}
|
||||||
|
label={"Capture Trace Logs"}
|
||||||
|
helpText={
|
||||||
|
"Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-6 justify-between",
|
||||||
|
!trace && "opacity-50 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={"flex-1 max-w-md"}>
|
||||||
|
<Label as={"div"}>Capture Duration</Label>
|
||||||
|
<HelpText margin={false}>
|
||||||
|
How long to capture trace logs before generating the bundle.
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<div className={"w-40 shrink-0"}>
|
||||||
|
<Input
|
||||||
|
type={"number"}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
value={traceMinutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1)))
|
||||||
|
}
|
||||||
|
customSuffix={"Minute(s)"}
|
||||||
|
disabled={!trace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BottomBar>
|
||||||
|
<Button variant={"primary"} size={"md"} onClick={run}>
|
||||||
|
Create Bundle
|
||||||
|
</Button>
|
||||||
|
</BottomBar>
|
||||||
|
</SectionGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
|
||||||
|
const cancelling = stage.kind === "cancelling";
|
||||||
|
return (
|
||||||
|
<StatusPanel
|
||||||
|
variant={"loading"}
|
||||||
|
title={stageLabel(stage)}
|
||||||
|
description={
|
||||||
|
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<Button variant={"secondary"} size={"xs"} onClick={onCancel} disabled={cancelling}>
|
||||||
|
{cancelling ? "Cancelling…" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultSection({
|
||||||
|
stage,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
stage: Extract<DebugStage, { kind: "done" } | { kind: "error" }>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
if (stage.kind === "error") {
|
||||||
|
return (
|
||||||
|
<StatusPanel
|
||||||
|
variant={"error"}
|
||||||
|
title={"Bundle failed"}
|
||||||
|
description={stage.message}
|
||||||
|
actions={
|
||||||
|
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoneResult({
|
||||||
|
result,
|
||||||
|
uploaded,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
result: DebugBundleResult;
|
||||||
|
uploaded: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const showKey = uploaded && Boolean(result.uploadedKey);
|
||||||
|
const uploadFailed = uploaded && !result.uploadedKey;
|
||||||
|
const onRevealPath = () => {
|
||||||
|
if (!result.path) return;
|
||||||
|
void DebugSvc.RevealFile(result.path).catch(() => {});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<StatusPanel
|
||||||
|
variant={"success"}
|
||||||
|
title={showKey ? "Debug bundle successfully uploaded!" : "Bundle saved"}
|
||||||
|
description={
|
||||||
|
showKey
|
||||||
|
? "Share the upload key below with NetBird support. A local copy was also saved on your device."
|
||||||
|
: "Your debug bundle has been saved locally."
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{showKey ? (
|
||||||
|
<Button variant={"primary"} size={"xs"} copy={result.uploadedKey}>
|
||||||
|
Copy Key
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
result.path && (
|
||||||
|
<Button variant={"primary"} size={"xs"} onClick={onRevealPath}>
|
||||||
|
<FolderOpen size={12} />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"w-full max-w-xs mx-auto flex flex-col gap-3"}>
|
||||||
|
{showKey && <Input value={result.uploadedKey} readOnly copy />}
|
||||||
|
|
||||||
|
{result.path && !showKey && (
|
||||||
|
<Input
|
||||||
|
value={result.path}
|
||||||
|
readOnly
|
||||||
|
customSuffix={
|
||||||
|
<button
|
||||||
|
type={"button"}
|
||||||
|
onClick={onRevealPath}
|
||||||
|
className={"pointer-events-auto hover:text-white transition-all"}
|
||||||
|
aria-label={"Open file location"}
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadFailed && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Upload failed
|
||||||
|
{result.uploadFailureReason ? `: ${result.uploadFailureReason}` : "."} The
|
||||||
|
bundle is still saved locally.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StatusPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BottomBar({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"w-full flex justify-end gap-3 px-8 py-5 border-t border-nb-gray-900 bg-nb-gray-935"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageLabel = (stage: DebugStage): string => {
|
||||||
|
switch (stage.kind) {
|
||||||
|
case "preparing-trace":
|
||||||
|
return "Switching to trace logging…";
|
||||||
|
case "reconnecting":
|
||||||
|
return "Reconnecting NetBird…";
|
||||||
|
case "capturing": {
|
||||||
|
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||||
|
return `Capturing logs — ${fmt(
|
||||||
|
stage.totalSec - stage.remainingSec,
|
||||||
|
)} / ${fmt(stage.totalSec)}`;
|
||||||
|
}
|
||||||
|
case "restoring-level":
|
||||||
|
return "Restoring previous log level…";
|
||||||
|
case "bundling":
|
||||||
|
return "Generating debug bundle…";
|
||||||
|
case "uploading":
|
||||||
|
return "Uploading to NetBird…";
|
||||||
|
case "cancelling":
|
||||||
|
return "Cancelling…";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
86
client/ui/frontend/src/modules/settings/useManagementUrl.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||||
|
|
||||||
|
export enum ManagementMode {
|
||||||
|
Cloud = "cloud",
|
||||||
|
SelfHosted = "selfhosted",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||||
|
|
||||||
|
function normalizeManagementUrl(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_PATTERN = new RegExp(
|
||||||
|
"^(https?:\\/\\/)?" +
|
||||||
|
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
|
||||||
|
"((\\d{1,3}\\.){3}\\d{1,3}))" +
|
||||||
|
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
|
||||||
|
"(\\?[;&a-z\\d%_.~+=-]*)?" +
|
||||||
|
"(\\#[-a-z\\d_]*)?$",
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
|
||||||
|
function isValidManagementUrl(input: string): boolean {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
return URL_PATTERN.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeFromUrl(url: string): ManagementMode {
|
||||||
|
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManagementUrl() {
|
||||||
|
const { config, saveField } = useSettings();
|
||||||
|
const [mode, setModeState] = useState<ManagementMode>(
|
||||||
|
modeFromUrl(config.managementUrl),
|
||||||
|
);
|
||||||
|
const [url, setUrl] = useState(
|
||||||
|
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setModeState(modeFromUrl(config.managementUrl));
|
||||||
|
if (config.managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||||
|
setUrl(config.managementUrl);
|
||||||
|
}
|
||||||
|
}, [config.managementUrl]);
|
||||||
|
|
||||||
|
const setMode = (next: ManagementMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
if (
|
||||||
|
next === ManagementMode.Cloud &&
|
||||||
|
config.managementUrl !== CLOUD_MANAGEMENT_URL
|
||||||
|
) {
|
||||||
|
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedUrl = normalizeManagementUrl(url);
|
||||||
|
const urlValid = isValidManagementUrl(url);
|
||||||
|
const targetUrl =
|
||||||
|
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
|
||||||
|
const dirty = targetUrl !== config.managementUrl;
|
||||||
|
const showError =
|
||||||
|
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
|
||||||
|
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||||
|
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||||
|
|
||||||
|
const save = () => saveField("managementUrl", targetUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
url,
|
||||||
|
setUrl,
|
||||||
|
displayUrl,
|
||||||
|
showError,
|
||||||
|
canSave,
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Skeleton from "react-loading-skeleton";
|
||||||
|
|
||||||
|
export const SkeletonSettings = () => {
|
||||||
|
return (
|
||||||
|
<div className={"gap-6 flex flex-col"}>
|
||||||
|
<div>
|
||||||
|
<Skeleton width={100} height={16} className={"mb-4"} />
|
||||||
|
<div>
|
||||||
|
<Skeleton width={100} height={14} />
|
||||||
|
<Skeleton width={400} height={10} />
|
||||||
|
</div>
|
||||||
|
<div className={"mt-3"}>
|
||||||
|
<Skeleton width={100} height={14} />
|
||||||
|
<Skeleton width={400} height={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton width={100} height={16} className={"mb-4"} />
|
||||||
|
<div>
|
||||||
|
<Skeleton width={100} height={14} />
|
||||||
|
<Skeleton width={400} height={10} />
|
||||||
|
<Skeleton width={300} height={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
import { Debug as DebugSvc } from "@bindings/services";
|
||||||
import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
import { Switch } from "../components/Switch";
|
import { Switch } from "../components/Switch";
|
||||||
|
|||||||
105
client/ui/frontend/src/screens/Debug.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Debug as DebugSvc } from "@bindings/services";
|
||||||
|
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
import { Input } from "../components/Input";
|
||||||
|
import { Switch } from "../components/Switch";
|
||||||
|
import { Card } from "../components/Card";
|
||||||
|
|
||||||
|
export default function Debug() {
|
||||||
|
const [anonymize, setAnonymize] = useState(true);
|
||||||
|
const [systemInfo, setSystemInfo] = useState(true);
|
||||||
|
const [upload, setUpload] = useState(false);
|
||||||
|
const [uploadUrl, setUploadUrl] = useState("");
|
||||||
|
const [logFiles, setLogFiles] = useState(0);
|
||||||
|
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [result, setResult] = useState<DebugBundleResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await DebugSvc.Bundle({
|
||||||
|
anonymize,
|
||||||
|
systemInfo,
|
||||||
|
uploadUrl: upload ? uploadUrl : "",
|
||||||
|
logFileCount: logFiles,
|
||||||
|
});
|
||||||
|
setResult(r);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Debug bundle</h1>
|
||||||
|
|
||||||
|
<Card className="space-y-4">
|
||||||
|
<Switch
|
||||||
|
checked={anonymize}
|
||||||
|
onChange={setAnonymize}
|
||||||
|
label="Anonymize"
|
||||||
|
description="Replace IPs and identifiers in the bundle."
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={systemInfo}
|
||||||
|
onChange={setSystemInfo}
|
||||||
|
label="Include system information"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={upload}
|
||||||
|
onChange={setUpload}
|
||||||
|
label="Upload on create"
|
||||||
|
/>
|
||||||
|
{upload && (
|
||||||
|
<Input
|
||||||
|
label="Upload URL"
|
||||||
|
value={uploadUrl}
|
||||||
|
onChange={(e) => setUploadUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Log file count"
|
||||||
|
type="number"
|
||||||
|
value={logFiles}
|
||||||
|
onChange={(e) => setLogFiles(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button onClick={run} disabled={running}>
|
||||||
|
{running ? "Generating…" : "Create bundle"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Card>
|
||||||
|
{result.path && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-nb-gray-500">Path:</span>{" "}
|
||||||
|
<span className="font-mono">{result.path}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result.uploadedKey && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-nb-gray-500">Uploaded key:</span>{" "}
|
||||||
|
<span className="font-mono">{result.uploadedKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result.uploadFailureReason && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
Upload failed: {result.uploadFailureReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
import { Networks as NetworksSvc } from "@bindings/services";
|
||||||
import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { Network } from "@bindings/services/models.js";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Tabs } from "../components/Tabs";
|
import { Tabs } from "../components/Tabs";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
|
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
|
||||||
import { useStatus } from "../hooks/useStatus";
|
import { useStatus } from "../hooks/useStatus";
|
||||||
import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { PeerStatus } from "@bindings/services/models.js";
|
||||||
import { Card } from "../components/Card";
|
import { Card } from "../components/Card";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
import { cn } from "../lib/cn";
|
import { cn } from "../lib/cn";
|
||||||
@@ -3,23 +3,23 @@ import { Plus, RefreshCw } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Profiles as ProfilesSvc,
|
Profiles as ProfilesSvc,
|
||||||
Connection,
|
Connection,
|
||||||
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
} from "@bindings/services";
|
||||||
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { Profile } from "@bindings/services/models.js";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
import { Card } from "../components/Card";
|
import { Card } from "../components/Card";
|
||||||
|
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||||
|
|
||||||
export default function Profiles() {
|
export default function Profiles() {
|
||||||
const [username, setUsername] = useState("");
|
const { username, loaded, refresh: refreshProfile, switchProfile } = useProfile();
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!username) return;
|
||||||
try {
|
try {
|
||||||
const u = username || (await ProfilesSvc.Username());
|
const list = await ProfilesSvc.List(username);
|
||||||
if (!username) setUsername(u);
|
|
||||||
const list = await ProfilesSvc.List(u);
|
|
||||||
setProfiles(list);
|
setProfiles(list);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -28,12 +28,12 @@ export default function Profiles() {
|
|||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
if (loaded) refresh();
|
||||||
}, [refresh]);
|
}, [loaded, refresh]);
|
||||||
|
|
||||||
const select = async (name: string) => {
|
const select = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
await ProfilesSvc.Switch({ profileName: name, username });
|
await switchProfile(name);
|
||||||
await Connection.Up({ profileName: name, username });
|
await Connection.Up({ profileName: name, username });
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -54,6 +54,7 @@ export default function Profiles() {
|
|||||||
if (name === "default") return;
|
if (name === "default") return;
|
||||||
try {
|
try {
|
||||||
await ProfilesSvc.Remove({ profileName: name, username });
|
await ProfilesSvc.Remove({ profileName: name, username });
|
||||||
|
await refreshProfile();
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
|
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
|
||||||
import { useStatus } from "../hooks/useStatus";
|
import { useStatus } from "../hooks/useStatus";
|
||||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
import { Connection } from "@bindings/services";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { cn } from "../lib/cn";
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Settings as SettingsSvc,
|
Settings as SettingsSvc,
|
||||||
Profiles as ProfilesSvc,
|
Profiles as ProfilesSvc,
|
||||||
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
} from "@bindings/services";
|
||||||
import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { Config } from "@bindings/services/models.js";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
import { Switch } from "../components/Switch";
|
import { Switch } from "../components/Switch";
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
|
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useStatus } from "../hooks/useStatus";
|
import { useStatus } from "../hooks/useStatus";
|
||||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
import { Connection } from "@bindings/services";
|
||||||
import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
import type { SystemEvent } from "@bindings/services/models.js";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Card } from "../components/Card";
|
import { Card } from "../components/Card";
|
||||||
import { cn } from "../lib/cn";
|
import { cn } from "../lib/cn";
|
||||||
|
import { NetBirdConnectToggle, ConnectionState } from "../components/NetBirdConnectToggle";
|
||||||
|
|
||||||
export default function Status() {
|
export default function Status() {
|
||||||
const { status, error } = useStatus();
|
const { status, error } = useStatus();
|
||||||
@@ -23,9 +24,15 @@ export default function Status() {
|
|||||||
// the user has no other way out. Disconnect is the manual unstick path.
|
// the user has no other way out. Disconnect is the manual unstick path.
|
||||||
const showLogin = !connected;
|
const showLogin = !connected;
|
||||||
|
|
||||||
|
const toggleState: ConnectionState =
|
||||||
|
connected ? ConnectionState.Connected
|
||||||
|
: connecting ? ConnectionState.Connecting
|
||||||
|
: ConnectionState.Disconnected;
|
||||||
|
|
||||||
const login = () => navigate("/login");
|
const login = () => navigate("/login");
|
||||||
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
|
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
|
||||||
const disconnect = () => Connection.Down().catch(console.error);
|
const disconnect = () => Connection.Down().catch(console.error);
|
||||||
|
const toggleConnection = () => (connected ? disconnect() : connect());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
@@ -99,6 +106,10 @@ export default function Status() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-center bg-nb-gray p-10">
|
||||||
|
<NetBirdConnectToggle state={toggleState} onClick={toggleConnection} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
61
client/ui/frontend/src/screens/Update.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Update as UpdateSvc } from "@bindings/services";
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export default function Update() {
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e)));
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (Date.now() - start > TIMEOUT_MS) {
|
||||||
|
setError("Update timed out.");
|
||||||
|
clearInterval(timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await UpdateSvc.GetInstallerResult();
|
||||||
|
if (r.success) {
|
||||||
|
setDone(true);
|
||||||
|
clearInterval(timer);
|
||||||
|
} else if (r.errorMsg) {
|
||||||
|
setError(r.errorMsg);
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// installer not finished yet
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
{done ? (
|
||||||
|
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
|
||||||
|
) : error ? (
|
||||||
|
<h1 className="text-xl font-semibold text-red-500">{error}</h1>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
|
||||||
|
<h1 className="text-xl font-semibold">Updating…</h1>
|
||||||
|
<p className="mt-1 text-sm text-nb-gray-500">
|
||||||
|
Please don't close this window.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,137 @@ const config: Config = {
|
|||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
theme: {
|
theme: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'monospace'],
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
"nb-gray": {
|
||||||
|
DEFAULT: "#181A1D",
|
||||||
|
50: "#f4f6f7",
|
||||||
|
100: "#e4e7e9",
|
||||||
|
200: "#cbd2d6",
|
||||||
|
250: "#b7c0c6",
|
||||||
|
300: "#a3adb5",
|
||||||
|
350: "#8f9ca8",
|
||||||
|
400: "#7c8994",
|
||||||
|
500: "#616e79",
|
||||||
|
600: "#535d67",
|
||||||
|
700: "#474e57",
|
||||||
|
800: "#3f444b",
|
||||||
|
850: "#363b40",
|
||||||
|
900: "#2e3238",
|
||||||
|
910: "#2b2f33",
|
||||||
|
920: "#25282d",
|
||||||
|
925: "#1e2123",
|
||||||
|
930: "#25282c",
|
||||||
|
935: "#1f2124",
|
||||||
|
940: "#1c1e21",
|
||||||
|
950: "#181a1d",
|
||||||
|
960: "#16181b",
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
50: "#F9FAFB",
|
||||||
|
100: "#F3F4F6",
|
||||||
|
200: "#E5E7EB",
|
||||||
|
300: "#D1D5DB",
|
||||||
|
400: "#9CA3AF",
|
||||||
|
500: "#6B7280",
|
||||||
|
600: "#4B5563",
|
||||||
|
700: "#374151",
|
||||||
|
800: "#1F2937",
|
||||||
|
900: "#111827",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
50: "#FDF2F2",
|
||||||
|
100: "#FDE8E8",
|
||||||
|
200: "#FBD5D5",
|
||||||
|
300: "#F8B4B4",
|
||||||
|
400: "#F98080",
|
||||||
|
500: "#F05252",
|
||||||
|
600: "#E02424",
|
||||||
|
700: "#C81E1E",
|
||||||
|
800: "#9B1C1C",
|
||||||
|
900: "#771D1D",
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
50: "#FDFDEA",
|
||||||
|
100: "#FDF6B2",
|
||||||
|
200: "#FCE96A",
|
||||||
|
300: "#FACA15",
|
||||||
|
400: "#E3A008",
|
||||||
|
500: "#C27803",
|
||||||
|
600: "#9F580A",
|
||||||
|
700: "#8E4B10",
|
||||||
|
800: "#723B13",
|
||||||
|
900: "#633112",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
50: "#F3FAF7",
|
||||||
|
100: "#DEF7EC",
|
||||||
|
200: "#BCF0DA",
|
||||||
|
300: "#84E1BC",
|
||||||
|
400: "#31C48D",
|
||||||
|
500: "#0E9F6E",
|
||||||
|
600: "#057A55",
|
||||||
|
700: "#046C4E",
|
||||||
|
800: "#03543F",
|
||||||
|
900: "#014737",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
50: "#EBF5FF",
|
||||||
|
100: "#E1EFFE",
|
||||||
|
200: "#C3DDFD",
|
||||||
|
300: "#A4CAFE",
|
||||||
|
400: "#76A9FA",
|
||||||
|
500: "#3F83F8",
|
||||||
|
600: "#1C64F2",
|
||||||
|
700: "#1A56DB",
|
||||||
|
800: "#1E429F",
|
||||||
|
900: "#233876",
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
50: "#F0F5FF",
|
||||||
|
100: "#E5EDFF",
|
||||||
|
200: "#CDDBFE",
|
||||||
|
300: "#B4C6FC",
|
||||||
|
400: "#8DA2FB",
|
||||||
|
500: "#6875F5",
|
||||||
|
600: "#5850EC",
|
||||||
|
700: "#5145CD",
|
||||||
|
800: "#42389D",
|
||||||
|
900: "#362F78",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
50: "#F6F5FF",
|
||||||
|
100: "#EDEBFE",
|
||||||
|
200: "#DCD7FE",
|
||||||
|
300: "#CABFFD",
|
||||||
|
400: "#AC94FA",
|
||||||
|
500: "#9061F9",
|
||||||
|
600: "#7E3AF2",
|
||||||
|
700: "#6C2BD9",
|
||||||
|
800: "#5521B5",
|
||||||
|
900: "#4A1D96",
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
50: "#FDF2F8",
|
||||||
|
100: "#FCE8F3",
|
||||||
|
200: "#FAD1E8",
|
||||||
|
300: "#F8B4D9",
|
||||||
|
400: "#F17EB8",
|
||||||
|
500: "#E74694",
|
||||||
|
600: "#D61F69",
|
||||||
|
700: "#BF125D",
|
||||||
|
800: "#99154B",
|
||||||
|
900: "#751A3D",
|
||||||
|
},
|
||||||
netbird: {
|
netbird: {
|
||||||
DEFAULT: "#f68330",
|
DEFAULT: "#f68330",
|
||||||
50: "#fff6ed",
|
50: "#fff6ed",
|
||||||
100: "#feecd6",
|
100: "#feecd6",
|
||||||
|
150: "#ffdfb8",
|
||||||
200: "#ffd4a6",
|
200: "#ffd4a6",
|
||||||
300: "#fab677",
|
300: "#fab677",
|
||||||
400: "#f68330",
|
400: "#f68330",
|
||||||
@@ -18,23 +143,31 @@ const config: Config = {
|
|||||||
700: "#be3e10",
|
700: "#be3e10",
|
||||||
800: "#973215",
|
800: "#973215",
|
||||||
900: "#7a2b14",
|
900: "#7a2b14",
|
||||||
|
950: "#421308",
|
||||||
},
|
},
|
||||||
"nb-gray": {
|
},
|
||||||
DEFAULT: "#181A1D",
|
backgroundImage: {
|
||||||
50: "#f4f6f7",
|
"conic-netbird": "conic-gradient(from 0deg, #e55311 0%, #f68330 10%, #e55311 20%, #e55311 100%)",
|
||||||
100: "#e4e7e9",
|
},
|
||||||
200: "#cbd2d6",
|
keyframes: {
|
||||||
300: "#a3adb5",
|
"pulse-reverse": {
|
||||||
400: "#7c8994",
|
"0%, 100%": { opacity: "1" },
|
||||||
500: "#616e79",
|
"50%": { opacity: "0.4" },
|
||||||
600: "#535d67",
|
|
||||||
700: "#474e57",
|
|
||||||
800: "#3f444b",
|
|
||||||
900: "#2e3238",
|
|
||||||
925: "#1e2123",
|
|
||||||
940: "#1c1e21",
|
|
||||||
950: "#181a1d",
|
|
||||||
},
|
},
|
||||||
|
"spin-slow": {
|
||||||
|
"0%": { transform: "rotate(0deg)" },
|
||||||
|
"100%": { transform: "rotate(360deg)" },
|
||||||
|
},
|
||||||
|
"ping-slow": {
|
||||||
|
"0%": { transform: "scale(1)", opacity: "1" },
|
||||||
|
"75%, 100%": { transform: "scale(2)", opacity: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"ping-slow": "ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite",
|
||||||
|
"pulse-slow": "pulse-reverse 2s cubic-bezier(0.5, 0, 0.6, 1) infinite",
|
||||||
|
"pulse-slower": "pulse-reverse 3s cubic-bezier(0.5, 0, 0.6, 1) infinite",
|
||||||
|
"spin-slow": "spin-slow 2s linear infinite",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,12 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@bindings/*": ["bindings/github.com/netbirdio/netbird/client/ui/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "bindings"],
|
"include": ["src", "bindings"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import wails from "@wailsio/runtime/plugins/vite";
|
import wails from "@wailsio/runtime/plugins/vite";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"@bindings": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"./bindings/github.com/netbirdio/netbird/client/ui",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [react(), wails("./bindings")],
|
plugins: [react(), wails("./bindings")],
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
278
client/ui/frontend/wails-go-api (1).md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# Wails Go API surface for the React frontend
|
||||||
|
|
||||||
|
All bindings live under `frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/`. Import them as:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Connection, Peers, Networks, Settings, Profiles, Debug, Update, Forwarding } from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services";
|
||||||
|
import * as $models from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services/models";
|
||||||
|
```
|
||||||
|
|
||||||
|
Every method returns `$CancellablePromise<T>` (Wails3 wrapper around a Promise — call `.cancel()` to abort the underlying gRPC stream / call).
|
||||||
|
|
||||||
|
## Push events
|
||||||
|
|
||||||
|
Subscribe with the Wails event API: `import { Events } from "@wailsio/runtime"`.
|
||||||
|
|
||||||
|
| Event name | Payload type | Fires on |
|
||||||
|
|---|---|---|
|
||||||
|
| `netbird:status` | `Status` | Daemon connection-state change (Connected / Connecting / Disconnected / Idle), peer-list change, address change, management/signal flip. **Replaces polling**. |
|
||||||
|
| `netbird:event` | `SystemEvent` | One push per daemon-emitted event (DNS/network/auth/connectivity/system). Drives toasts and the event log. |
|
||||||
|
| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version. Show the update menu/banner. |
|
||||||
|
| `netbird:update:progress` | `UpdateProgress` | `action:"show"` → open the update progress page; `action:"hide"` → close. |
|
||||||
|
|
||||||
|
Calling `Peers.Watch()` once at boot starts both backend stream loops; both self-restart with backoff on errors.
|
||||||
|
|
||||||
|
## Connection lifecycle — `Connection`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Connection.Up(p: UpParams): Promise<void>
|
||||||
|
Connection.Down(): Promise<void>
|
||||||
|
Connection.Login(p: LoginParams): Promise<LoginResult>
|
||||||
|
Connection.WaitSSOLogin(p: WaitSSOParams): Promise<string> // returns email/userInfo
|
||||||
|
Connection.Logout(p: LogoutParams): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Up flow**: call `Login` first; if `LoginResult.needsSsoLogin === true` open `verificationUriComplete` in the browser, then call `WaitSSOLogin` with `{ userCode: LoginResult.userCode, hostname: ... }`. Once that resolves call `Up`.
|
||||||
|
- **Down flow**: just `Down()`. The daemon transitions to `Idle`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class LoginParams { profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }
|
||||||
|
class LoginResult { needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }
|
||||||
|
class WaitSSOParams { userCode, hostname: string }
|
||||||
|
class UpParams { profileName, username: string }
|
||||||
|
class LogoutParams { profileName, username: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status / peer list — `Peers`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Peers.Get(): Promise<Status> // one-shot snapshot
|
||||||
|
Peers.Watch(): Promise<void> // call once at boot to enable push events
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Status {
|
||||||
|
status: string // "Idle" | "Connecting" | "Connected" | "SessionExpired" (see below)
|
||||||
|
daemonVersion: string
|
||||||
|
management: PeerLink
|
||||||
|
signal: PeerLink
|
||||||
|
local: LocalPeer
|
||||||
|
peers: PeerStatus[]
|
||||||
|
events: SystemEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerLink {
|
||||||
|
url: string
|
||||||
|
connected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalPeer {
|
||||||
|
ip, pubKey, fqdn: string
|
||||||
|
networks: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerStatus {
|
||||||
|
ip, pubKey, fqdn: string
|
||||||
|
connStatus: string // "Connected" | "Connecting" | "Idle"
|
||||||
|
connStatusUpdateUnix: number // unix seconds
|
||||||
|
relayed: boolean
|
||||||
|
localIceCandidateType, remoteIceCandidateType: string
|
||||||
|
localIceCandidateEndpoint, remoteIceCandidateEndpoint: string
|
||||||
|
bytesRx, bytesTx: number
|
||||||
|
latencyMs: number
|
||||||
|
relayAddress: string // populated when relayed
|
||||||
|
lastHandshakeUnix: number
|
||||||
|
rosenpassEnabled: boolean
|
||||||
|
networks: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemEvent {
|
||||||
|
id: string
|
||||||
|
severity: string // "info" | "warning" | "error" | "critical"
|
||||||
|
category: string // "network" | "dns" | "authentication" | "connectivity" | "system"
|
||||||
|
message: string // technical / log message
|
||||||
|
userMessage: string // human-friendly message — render this
|
||||||
|
timestamp: number // unix seconds
|
||||||
|
metadata: Record<string, string>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection-state values
|
||||||
|
|
||||||
|
The `Status.status` field uses these literal strings (from the daemon):
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `"Idle"` | Disconnected — Up not invoked, or Down completed |
|
||||||
|
| `"Connecting"` | Up in progress |
|
||||||
|
| `"Connected"` | Tunnel up |
|
||||||
|
| `"SessionExpired"` | SSO token expired — needs Login again |
|
||||||
|
|
||||||
|
(The Fyne UI also reads a synthetic `"Error"` label for some failed states; check `events` for details.)
|
||||||
|
|
||||||
|
### ICE candidate type values
|
||||||
|
|
||||||
|
`localIceCandidateType` / `remoteIceCandidateType` are pion/ICE strings: `"host"`, `"srflx"`, `"prflx"`, `"relay"`, or `""` while connecting.
|
||||||
|
|
||||||
|
## Networks — `Networks`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Networks.List(): Promise<Network[]>
|
||||||
|
Networks.Select(p: SelectNetworksParams): Promise<void>
|
||||||
|
Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Network {
|
||||||
|
id, range: string // range is a CIDR
|
||||||
|
selected: boolean
|
||||||
|
domains: string[] // empty unless this is a domain network
|
||||||
|
resolvedIps: Record<string, string[]> // domain -> IPs
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectNetworksParams {
|
||||||
|
networkIds: string[]
|
||||||
|
append: boolean // false = replace selection, true = merge with existing
|
||||||
|
all: boolean // true = ignore networkIds and target every network (Select-All / Deselect-All)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Fyne UI's All / Overlapping / Exit-node tabs are filters over the same `List()` result:
|
||||||
|
- **Exit-node**: `range === "0.0.0.0/0" || range === "::/0"`
|
||||||
|
- **Overlapping**: client-side detection of CIDR overlap among `range` values
|
||||||
|
- **All**: everything
|
||||||
|
|
||||||
|
## Forwarding / exposed services — `Forwarding`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Forwarding.List(): Promise<ForwardingRule[]>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class ForwardingRule {
|
||||||
|
protocol: string // "tcp" | "udp"
|
||||||
|
destinationPort: PortInfo
|
||||||
|
translatedAddress, translatedHostname: string
|
||||||
|
translatedPort: PortInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
class PortInfo { // exactly one field is populated
|
||||||
|
port?: number
|
||||||
|
range?: PortRange
|
||||||
|
}
|
||||||
|
|
||||||
|
class PortRange { start, end: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Profiles — `Profiles`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Profiles.List(username: string): Promise<Profile[]>
|
||||||
|
Profiles.GetActive(): Promise<ActiveProfile>
|
||||||
|
Profiles.Switch(p: ProfileRef): Promise<void>
|
||||||
|
Profiles.Add(p: ProfileRef): Promise<void>
|
||||||
|
Profiles.Remove(p: ProfileRef): Promise<void>
|
||||||
|
Profiles.Username(): Promise<string> // current OS username
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class Profile { name: string; isActive: boolean }
|
||||||
|
class ProfileRef { profileName, username: string }
|
||||||
|
class ActiveProfile { profileName, username: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings / config — `Settings`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Settings.GetConfig(p: ConfigParams): Promise<Config>
|
||||||
|
Settings.SetConfig(p: SetConfigParams): Promise<void>
|
||||||
|
Settings.GetFeatures(): Promise<Features>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class ConfigParams { profileName, username: string } // identifies which profile's config
|
||||||
|
|
||||||
|
class Config {
|
||||||
|
managementUrl, adminUrl, configFile, logFile, preSharedKey: string
|
||||||
|
interfaceName: string; wireguardPort, mtu: number
|
||||||
|
disableAutoConnect, serverSshAllowed: boolean
|
||||||
|
rosenpassEnabled, rosenpassPermissive: boolean
|
||||||
|
disableNotifications, lazyConnectionEnabled, blockInbound: boolean
|
||||||
|
networkMonitor, disableClientRoutes, disableServerRoutes: boolean
|
||||||
|
disableDns, blockLanAccess: boolean
|
||||||
|
enableSshRoot, enableSshSftp: boolean
|
||||||
|
enableSshLocalPortForwarding, enableSshRemotePortForwarding: boolean
|
||||||
|
disableSshAuth: boolean
|
||||||
|
sshJwtCacheTtl: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class SetConfigParams {
|
||||||
|
// identity (always required)
|
||||||
|
profileName, username: string
|
||||||
|
// any field below is optional — only the ones you set are pushed to the daemon
|
||||||
|
managementUrl?, adminUrl?, ...
|
||||||
|
// ... same shape as Config
|
||||||
|
}
|
||||||
|
|
||||||
|
class Features {
|
||||||
|
// feature flags from the daemon — hide UI sections when these are true
|
||||||
|
disableProfiles, disableUpdateSettings, disableNetworks: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SetConfig` is partial — supply only the fields you want to change, plus `profileName` + `username`. Booleans use Go pointer-presence under the hood; on the TS side undefined / missing means "leave as-is".
|
||||||
|
|
||||||
|
## Debug bundle / log level — `Debug`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Debug.GetLogLevel(): Promise<LogLevel>
|
||||||
|
Debug.SetLogLevel(lvl: LogLevel): Promise<void>
|
||||||
|
Debug.Bundle(p: DebugBundleParams): Promise<DebugBundleResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class LogLevel { level: string } // "trace" | "debug" | "info" | "warning" | "error" | "panic"
|
||||||
|
|
||||||
|
class DebugBundleParams {
|
||||||
|
anonymize: boolean
|
||||||
|
systemInfo: boolean
|
||||||
|
uploadUrl: string // empty string = no upload
|
||||||
|
logFileCount: number // 0 = default
|
||||||
|
}
|
||||||
|
|
||||||
|
class DebugBundleResult {
|
||||||
|
path: string // local path of the generated bundle
|
||||||
|
uploadedKey: string // populated when uploadUrl was set
|
||||||
|
uploadFailureReason: string // populated on upload error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update flow — `Update`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Update.Trigger(): Promise<UpdateResult> // start the install
|
||||||
|
Update.GetInstallerResult(): Promise<UpdateResult> // poll the install outcome (long-running)
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class UpdateResult { success: boolean; errorMsg: string }
|
||||||
|
|
||||||
|
class UpdateAvailable { // payload of "netbird:update:available"
|
||||||
|
version: string
|
||||||
|
enforced: boolean // true = management server requires it
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateProgress { // payload of "netbird:update:progress"
|
||||||
|
action: string // "show" | "hide"
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical flow:
|
||||||
|
1. Listen for `"netbird:update:available"` → show the "Update X.Y.Z" affordance.
|
||||||
|
2. User clicks → call `Update.Trigger()`.
|
||||||
|
3. The page that shows the install progress polls `GetInstallerResult()` (15-min timeout). On `success: true` the daemon will exit; the app should `app.Quit()` (or restart). On `success: false` show `errorMsg`.
|
||||||
|
|
||||||
|
## Toast notifications
|
||||||
|
|
||||||
|
The tray sends OS notifications via `application/services/notifications` automatically for `netbird:event` events that have `userMessage`. The frontend doesn't need to do anything for that; the data is also delivered via `netbird:event` if you want to render an in-window log.
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
@@ -66,6 +67,15 @@ func main() {
|
|||||||
// declared before app.New so the closure has a stable reference.
|
// declared before app.New so the closure has a stable reference.
|
||||||
var tray *Tray
|
var tray *Tray
|
||||||
|
|
||||||
|
// On macOS, application.Options.Icon is fed into NSApplication's
|
||||||
|
// setApplicationIconImage at startup, which would override the bundle
|
||||||
|
// icon (Assets.car / icons.icns) the OS already picked. We want the
|
||||||
|
// bundle's squircle to stay, so suppress it on darwin.
|
||||||
|
appIcon := iconWindow
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
appIcon = nil
|
||||||
|
}
|
||||||
|
|
||||||
app := application.New(application.Options{
|
app := application.New(application.Options{
|
||||||
// Windows uses Name as the AppUserModelID for toast notifications
|
// Windows uses Name as the AppUserModelID for toast notifications
|
||||||
// (see notifications_windows.go: cfg.Name -> wn.appName -> AppID)
|
// (see notifications_windows.go: cfg.Name -> wn.appName -> AppID)
|
||||||
@@ -77,7 +87,7 @@ func main() {
|
|||||||
// CustomActivator registry value is orphaned.
|
// CustomActivator registry value is orphaned.
|
||||||
Name: "NetBird",
|
Name: "NetBird",
|
||||||
Description: "NetBird desktop client",
|
Description: "NetBird desktop client",
|
||||||
Icon: iconWindow,
|
Icon: appIcon,
|
||||||
Assets: application.AssetOptions{
|
Assets: application.AssetOptions{
|
||||||
Handler: application.AssetFileServerFS(assets),
|
Handler: application.AssetFileServerFS(assets),
|
||||||
},
|
},
|
||||||
@@ -116,8 +126,10 @@ func main() {
|
|||||||
|
|
||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: "NetBird",
|
Title: "NetBird",
|
||||||
Width: 960,
|
Width: 925,
|
||||||
Height: 640,
|
MinWidth: 925,
|
||||||
|
Height: 615,
|
||||||
|
MinHeight: 615,
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
BackgroundColour: application.NewRGB(24, 26, 29),
|
BackgroundColour: application.NewRGB(24, 26, 29),
|
||||||
URL: "/",
|
URL: "/",
|
||||||
@@ -125,6 +137,7 @@ func main() {
|
|||||||
InvisibleTitleBarHeight: 38,
|
InvisibleTitleBarHeight: 38,
|
||||||
Backdrop: application.MacBackdropTranslucent,
|
Backdrop: application.MacBackdropTranslucent,
|
||||||
TitleBar: application.MacTitleBarHiddenInset,
|
TitleBar: application.MacTitleBarHiddenInset,
|
||||||
|
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||||
},
|
},
|
||||||
Linux: application.LinuxWindow{
|
Linux: application.LinuxWindow{
|
||||||
Icon: iconWindow,
|
Icon: iconWindow,
|
||||||
|
|||||||