mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-07 08:29:54 +00:00
Compare commits
45 Commits
ui-tray-li
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f1142355 | ||
|
|
9ecc083139 | ||
|
|
efd874efac | ||
|
|
5877880789 | ||
|
|
4427aaa31f | ||
|
|
0ce3fbf5af | ||
|
|
3967864172 | ||
|
|
296d3f124b | ||
|
|
adf1fe1858 | ||
|
|
1108808ab1 | ||
|
|
db371a0263 | ||
|
|
512899d82d | ||
|
|
1412b06999 | ||
|
|
ca6f6d88cb | ||
|
|
e298747203 | ||
|
|
5993ec6e43 | ||
|
|
cba4a8a63b | ||
|
|
93e068f753 | ||
|
|
94065a8058 | ||
|
|
eac6d501c3 | ||
|
|
deeae30612 | ||
|
|
f3cdf163e1 | ||
|
|
d7263a6be9 | ||
|
|
64199209cf | ||
|
|
166c6118e2 | ||
|
|
3e61ccb162 | ||
|
|
a48c20d8d8 | ||
|
|
2b57a7d43b | ||
|
|
1710868a09 | ||
|
|
7798b7cf14 | ||
|
|
179966b000 | ||
|
|
48265a0143 | ||
|
|
fa1e241aea | ||
|
|
9a76507b14 | ||
|
|
c3a0c1beeb | ||
|
|
45095818bc | ||
|
|
1f74ee9c78 | ||
|
|
562a538e91 | ||
|
|
50b26a21fd | ||
|
|
fefd0da7bf | ||
|
|
d1f3d88f0d | ||
|
|
a71ef1bab0 | ||
|
|
16743c4ce5 | ||
|
|
3c0a9c314b | ||
|
|
e7c9182ff9 |
18
.coderabbit.yaml
Normal file
18
.coderabbit.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
language: en-US
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
review_status: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
path_filters:
|
||||
- "!**/*.tsx"
|
||||
- "!**/*.ts"
|
||||
- "!**/*.js"
|
||||
- "!**/*.svg"
|
||||
chat:
|
||||
auto_reply: true
|
||||
8
.github/workflows/golang-test-darwin.yml
vendored
8
.github/workflows/golang-test-darwin.yml
vendored
@@ -53,5 +53,11 @@ jobs:
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
# root, which has no Go files.
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
60
.github/workflows/golang-test-linux.yml
vendored
60
.github/workflows/golang-test-linux.yml
vendored
@@ -166,7 +166,15 @@ jobs:
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
# root, which has no Go files.
|
||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
@@ -284,9 +292,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test ${{ matrix.raceFlag }} \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-timeout 10m -p 1 ./relay/... ./shared/relay/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,relay
|
||||
|
||||
test_proxy:
|
||||
name: "Proxy / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -334,7 +350,15 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test -timeout 10m -p 1 ./proxy/...
|
||||
go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,proxy
|
||||
|
||||
test_signal:
|
||||
name: "Signal / Unit"
|
||||
@@ -385,9 +409,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-timeout 10m ./signal/... ./shared/signal/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,signal
|
||||
|
||||
test_management:
|
||||
name: "Management / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -453,10 +485,18 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
||||
-timeout 20m ./management/... ./shared/management/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,management
|
||||
|
||||
benchmark:
|
||||
name: "Management / Benchmark"
|
||||
needs: [build-cache]
|
||||
@@ -695,6 +735,14 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=integration \
|
||||
go test -tags=integration -coverprofile=coverage.txt \
|
||||
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
||||
-timeout 20m ./management/server/http/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: integration,management
|
||||
|
||||
39
.github/workflows/proto-version-check.yml
vendored
39
.github/workflows/proto-version-check.yml
vendored
@@ -20,15 +20,30 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const modifiedPbFiles = files.filter(
|
||||
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
|
||||
);
|
||||
if (modifiedPbFiles.length === 0) {
|
||||
console.log('No modified .pb.go files to check');
|
||||
// Cover renamed .pb.go files in addition to plain edits.
|
||||
// Renamed entries land under the new path with previous_filename
|
||||
// pointing at the base-side name, so we read the base content
|
||||
// from the old path when present.
|
||||
const changedPbFiles = files
|
||||
.filter(f => (f.status === 'modified' || f.status === 'renamed')
|
||||
&& f.filename.endsWith('.pb.go'))
|
||||
.map(f => ({
|
||||
headPath: f.filename,
|
||||
basePath: f.previous_filename || f.filename,
|
||||
}));
|
||||
if (changedPbFiles.length === 0) {
|
||||
console.log('No modified or renamed .pb.go files to check');
|
||||
return;
|
||||
}
|
||||
|
||||
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
// Matches the generator version headers protoc writes at the top
|
||||
// of generated files:
|
||||
// // protoc v3.21.12
|
||||
// // protoc-gen-go v1.26.0
|
||||
// // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
|
||||
// The optional "- " prefix and the optional -gen-go / -gen-go-grpc
|
||||
// suffixes keep the *_grpc.pb.go headers in scope.
|
||||
const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
|
||||
const baseSha = context.payload.pull_request.base.sha;
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
|
||||
@@ -55,20 +70,22 @@ jobs:
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of modifiedPbFiles) {
|
||||
for (const file of changedPbFiles) {
|
||||
const [base, head] = await Promise.all([
|
||||
getVersionHeader(file.filename, baseSha),
|
||||
getVersionHeader(file.filename, headSha),
|
||||
getVersionHeader(file.basePath, baseSha),
|
||||
getVersionHeader(file.headPath, headSha),
|
||||
]);
|
||||
if (!base.ok || !head.ok) {
|
||||
core.warning(
|
||||
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
`Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
||||
violations.push({
|
||||
file: file.filename,
|
||||
file: file.basePath === file.headPath
|
||||
? file.headPath
|
||||
: `${file.basePath} → ${file.headPath}`,
|
||||
base: base.lines,
|
||||
head: head.lines,
|
||||
});
|
||||
|
||||
@@ -196,6 +196,7 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
@@ -210,6 +211,7 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_rpm
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
|
||||
@@ -70,6 +70,8 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
@@ -80,7 +82,7 @@ nfpms:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
@@ -92,6 +94,8 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
@@ -102,7 +106,7 @@ nfpms:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/upload-server/types"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
@@ -100,6 +101,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -298,6 +300,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -432,6 +435,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
|
||||
SyncResponse: syncResponse,
|
||||
LogPath: logFilePath,
|
||||
CPUProfile: nil,
|
||||
DaemonVersion: version.NetbirdVersion(), // acting as daemon
|
||||
},
|
||||
debug.BundleConfig{
|
||||
IncludeSystemInfo: true,
|
||||
|
||||
@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
|
||||
}
|
||||
|
||||
// Common setup for service control commands
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
|
||||
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||
SetFlagsFromEnvVars(serviceCmd)
|
||||
|
||||
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
if consoleLog {
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := newSVCConfig()
|
||||
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
|
||||
SetupCloseHandler(ctx, cancel)
|
||||
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
|
||||
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
|
||||
Short: "starts NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
|
||||
Short: "stops NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
|
||||
Short: "restarts NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
|
||||
Short: "shows NetBird service status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ var (
|
||||
Short: "Print the NetBird's client application version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
cmd.Println(version.NetbirdVersion())
|
||||
out := version.NetbirdVersion()
|
||||
if version.IsDevelopmentVersion(out) {
|
||||
if commit := version.NetbirdCommit(); commit != "" {
|
||||
out += "-" + commit
|
||||
}
|
||||
}
|
||||
cmd.Println(out)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
|
||||
return 0
|
||||
}
|
||||
|
||||
if pc := f.endpoint.capture.Load(); pc != nil {
|
||||
(*pc).Offer(fullPacket, true)
|
||||
}
|
||||
|
||||
return len(fullPacket)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@@ -355,6 +356,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return wrapErr(err)
|
||||
}
|
||||
engineConfig.TempDir = mobileDependency.TempDir
|
||||
// Leave StateDir empty when there is no state path so a disk-backed
|
||||
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
|
||||
if path != "" {
|
||||
engineConfig.StateDir = filepath.Dir(path)
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
|
||||
@@ -254,6 +254,8 @@ type BundleGenerator struct {
|
||||
capturePath string
|
||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
clientMetrics MetricsExporter
|
||||
daemonVersion string
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
includeSystemInfo bool
|
||||
@@ -278,6 +280,8 @@ type GeneratorDependencies struct {
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
ClientMetrics MetricsExporter
|
||||
DaemonVersion string
|
||||
CliVersion string
|
||||
}
|
||||
|
||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||
@@ -299,6 +303,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
capturePath: deps.CapturePath,
|
||||
refreshStatus: deps.RefreshStatus,
|
||||
clientMetrics: deps.ClientMetrics,
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
@@ -459,9 +465,11 @@ func (g *BundleGenerator) addStatus() error {
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
|
||||
statusReader := strings.NewReader(statusOutput)
|
||||
@@ -1039,7 +1047,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
return
|
||||
}
|
||||
|
||||
pattern := filepath.Join(logDir, "client-*.log.gz")
|
||||
// This regex will match both logs rotated by us and logrotate on linux
|
||||
pattern := filepath.Join(logDir, "client*.log.*")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
log.Warnf("failed to glob rotated logs: %v", err)
|
||||
@@ -1072,7 +1081,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
name := filepath.Base(files[i])
|
||||
if err := g.addSingleLogFileGz(files[i], name); err != nil {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
err = g.addSingleLogFileGz(files[i], name)
|
||||
} else {
|
||||
err = g.addSingleLogfile(files[i], name)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("failed to add rotated log %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
103
client/internal/debug/debug_logfiles_test.go
Normal file
103
client/internal/debug/debug_logfiles_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
|
||||
// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
|
||||
// and gzipped), and skips unrelated files.
|
||||
func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
|
||||
writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
|
||||
|
||||
timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
|
||||
writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
|
||||
|
||||
logrotatePlain := "client.log.1"
|
||||
writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
|
||||
|
||||
logrotateGz := "client.log.2.gz"
|
||||
writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
|
||||
|
||||
names := runAddRotatedLogFiles(t, dir, 10)
|
||||
|
||||
require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
|
||||
require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
|
||||
require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
|
||||
require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
|
||||
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
||||
}
|
||||
|
||||
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
||||
// logFileCount rotated files are bundled, ordered by mtime.
|
||||
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
oldest := filepath.Join(dir, "client.log.3")
|
||||
middle := filepath.Join(dir, "client.log.2")
|
||||
newest := filepath.Join(dir, "client.log.1")
|
||||
writeFile(t, oldest, "old\n")
|
||||
writeFile(t, middle, "mid\n")
|
||||
writeFile(t, newest, "new\n")
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(newest, now, now))
|
||||
|
||||
names := runAddRotatedLogFiles(t, dir, 2)
|
||||
|
||||
require.Contains(t, names, "client.log.1")
|
||||
require.Contains(t, names, "client.log.2")
|
||||
require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
|
||||
}
|
||||
|
||||
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
||||
// zip writer and returns the set of entry names that ended up in the archive.
|
||||
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
g := &BundleGenerator{
|
||||
archive: zip.NewWriter(&buf),
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
g.addRotatedLogFiles(dir)
|
||||
require.NoError(t, g.archive.Close())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make(map[string]struct{}, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
names[f.Name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func writeGzFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
_, err := io.WriteString(gw, content)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gw.Close())
|
||||
require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
@@ -56,6 +55,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
@@ -72,6 +72,7 @@ import (
|
||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
||||
@@ -148,6 +149,10 @@ type EngineConfig struct {
|
||||
|
||||
LogPath string
|
||||
TempDir string
|
||||
|
||||
// StateDir is the directory holding the state file. The sync response
|
||||
// (network map) is serialized here on platforms that persist it to disk.
|
||||
StateDir string
|
||||
}
|
||||
|
||||
// EngineServices holds the external service dependencies required by the Engine.
|
||||
@@ -226,10 +231,15 @@ type Engine struct {
|
||||
|
||||
afpacketCapture *capture.AFPacketCapture
|
||||
|
||||
// Sync response persistence (protected by syncRespMux)
|
||||
syncRespMux sync.RWMutex
|
||||
persistSyncResponse bool
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
// Sync response persistence (protected by syncRespMux).
|
||||
// syncStore is nil unless persistence has been enabled; its presence is
|
||||
// what marks persistence as active. The backend (disk or memory) is
|
||||
// selected per-platform; see the syncstore package. syncStoreDir is where
|
||||
// a disk-backed store serializes to.
|
||||
syncRespMux sync.RWMutex
|
||||
syncStore syncstore.Store
|
||||
syncStoreDir string
|
||||
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// auto-update
|
||||
@@ -306,6 +316,7 @@ func NewEngine(
|
||||
jobExecutor: jobexec.NewExecutor(),
|
||||
clientMetrics: services.ClientMetrics,
|
||||
updateManager: services.UpdateManager,
|
||||
syncStoreDir: config.StateDir,
|
||||
}
|
||||
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
||||
// session expiry deadline. Deadline-change ticks come for free via
|
||||
@@ -943,26 +954,27 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
|
||||
// Read the storage-enabled flag under the syncRespMux too.
|
||||
e.syncRespMux.RLock()
|
||||
enabled := e.persistSyncResponse
|
||||
e.syncRespMux.RUnlock()
|
||||
|
||||
// Store sync response if persistence is enabled
|
||||
if enabled {
|
||||
e.syncRespMux.Lock()
|
||||
e.latestSyncResponse = update
|
||||
e.syncRespMux.Unlock()
|
||||
|
||||
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
|
||||
}
|
||||
|
||||
// only apply new changes and ignore old ones
|
||||
if err := e.updateNetworkMap(nm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist sync response only after updateNetworkMap accepted and applied the update,
|
||||
// so GetLatestSyncResponse() never returns state the engine did not actually apply.
|
||||
// Done under the dedicated lock (syncRespMux), not under syncMsgMux.
|
||||
// A non-nil syncStore is what marks persistence as enabled. Hold the lock for
|
||||
// the whole Set so the store cannot be cleared (disabled / engine close)
|
||||
// mid-call and have this write resurrect a file that was just removed.
|
||||
e.syncRespMux.RLock()
|
||||
if e.syncStore != nil {
|
||||
if err := e.syncStore.Set(update); err != nil {
|
||||
log.Errorf("failed to persist sync response: %v", err)
|
||||
} else {
|
||||
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
|
||||
}
|
||||
}
|
||||
e.syncRespMux.RUnlock()
|
||||
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
|
||||
|
||||
return nil
|
||||
@@ -1094,6 +1106,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
|
||||
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
|
||||
state.FQDN = conf.GetFqdn()
|
||||
state.WgPort = e.config.WgPort
|
||||
|
||||
e.statusRecorder.UpdateLocalPeerState(state)
|
||||
|
||||
@@ -1172,6 +1185,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
LogPath: e.config.LogPath,
|
||||
TempDir: e.config.TempDir,
|
||||
ClientMetrics: e.clientMetrics,
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
RefreshStatus: func() {
|
||||
e.RunHealthProbes(e.ctx, true)
|
||||
},
|
||||
@@ -1844,6 +1858,18 @@ func (e *Engine) close() {
|
||||
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
|
||||
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
|
||||
}
|
||||
|
||||
// Drop any persisted sync response so its network map does not linger on
|
||||
// disk after the engine stops (and cannot leak into a later run).
|
||||
e.syncRespMux.Lock()
|
||||
store := e.syncStore
|
||||
e.syncStore = nil
|
||||
e.syncRespMux.Unlock()
|
||||
if store != nil {
|
||||
if err := store.Clear(); err != nil {
|
||||
log.Warnf("failed to clear persisted sync response on close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
@@ -2186,45 +2212,42 @@ func (e *Engine) stopDNSServer() {
|
||||
e.statusRecorder.UpdateDNSStates(nsGroupStates)
|
||||
}
|
||||
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence.
|
||||
// The store is only instantiated while persistence is enabled; construction
|
||||
// itself drops any stale data left over from an earlier run (see syncstore).
|
||||
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
|
||||
e.syncRespMux.Lock()
|
||||
defer e.syncRespMux.Unlock()
|
||||
|
||||
if enabled == e.persistSyncResponse {
|
||||
if enabled == (e.syncStore != nil) {
|
||||
return
|
||||
}
|
||||
e.persistSyncResponse = enabled
|
||||
log.Debugf("Sync response persistence is set to %t", enabled)
|
||||
|
||||
if !enabled {
|
||||
e.latestSyncResponse = nil
|
||||
if err := e.syncStore.Clear(); err != nil {
|
||||
log.Warnf("failed to clear persisted sync response: %v", err)
|
||||
}
|
||||
e.syncStore = nil
|
||||
return
|
||||
}
|
||||
|
||||
e.syncStore = syncstore.New(e.syncStoreDir)
|
||||
}
|
||||
|
||||
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
|
||||
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
||||
// Hold the lock for the whole Get so the store cannot be cleared
|
||||
// (disabled / engine close) mid-call.
|
||||
e.syncRespMux.RLock()
|
||||
enabled := e.persistSyncResponse
|
||||
latest := e.latestSyncResponse
|
||||
e.syncRespMux.RUnlock()
|
||||
defer e.syncRespMux.RUnlock()
|
||||
|
||||
if !enabled {
|
||||
if e.syncStore == nil {
|
||||
return nil, errors.New("sync response persistence is disabled")
|
||||
}
|
||||
|
||||
if latest == nil {
|
||||
//nolint:nilnil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
|
||||
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to clone sync response")
|
||||
}
|
||||
|
||||
return sr, nil
|
||||
//nolint:nilnil
|
||||
return e.syncStore.Get()
|
||||
}
|
||||
|
||||
// GetWgAddr returns the wireguard address
|
||||
@@ -2260,7 +2283,7 @@ func (e *Engine) updateDNSForwarder(
|
||||
enabled bool,
|
||||
fwdEntries []*dnsfwd.ForwarderEntry,
|
||||
) {
|
||||
if e.config.DisableServerRoutes {
|
||||
if e.config.DisableServerRoutes || e.config.BlockInbound {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
|
||||
nbversion "github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -11,7 +13,7 @@ var (
|
||||
)
|
||||
|
||||
func IsSupported(agentVersion string) bool {
|
||||
if agentVersion == "development" {
|
||||
if nbversion.IsDevelopmentVersion(agentVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ type LocalPeerState struct {
|
||||
PubKey string
|
||||
KernelInterface bool
|
||||
FQDN string
|
||||
WgPort int
|
||||
Routes map[string]struct{}
|
||||
}
|
||||
|
||||
@@ -334,8 +335,12 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||
|
||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||
// address so dual-stack peers are reachable on either family. Returns the
|
||||
// zero State and false when no peer matches or the input is empty.
|
||||
// address so dual-stack peers are reachable on either family. Searches
|
||||
// both d.peers and d.offlinePeers — peers that have been moved into
|
||||
// the offline slice by ReplaceOfflinePeers are still part of the
|
||||
// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
|
||||
// need to recognise them rather than treating them as unknown. Returns
|
||||
// the zero State and false when no peer matches or the input is empty.
|
||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
if ip == "" {
|
||||
return State{}, false
|
||||
@@ -348,6 +353,11 @@ func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
return state, true
|
||||
}
|
||||
}
|
||||
for _, state := range d.offlinePeers {
|
||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
||||
return state, true
|
||||
}
|
||||
}
|
||||
return State{}, false
|
||||
}
|
||||
|
||||
@@ -1518,6 +1528,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
||||
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
||||
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
||||
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||
|
||||
@@ -90,6 +90,28 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||
}
|
||||
|
||||
// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
|
||||
// been moved into the offline slice via ReplaceOfflinePeers. Callers
|
||||
// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
|
||||
// rather than unknown — otherwise authentication / DNS filtering treats
|
||||
// known-but-offline peers as foreign IPs.
|
||||
func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
status.ReplaceOfflinePeers([]State{
|
||||
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
|
||||
})
|
||||
|
||||
state, ok := status.PeerStateByIP("100.64.0.20")
|
||||
req.True(ok, "offline peer must resolve by IPv4 tunnel address")
|
||||
req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
|
||||
|
||||
state, ok = status.PeerStateByIP("fd00::20")
|
||||
req.True(ok, "offline peer must resolve by IPv6 tunnel address")
|
||||
req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
|
||||
}
|
||||
|
||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||
key := "abc"
|
||||
fqdn := "peer-a.netbird.local"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
@@ -59,3 +60,22 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
// account email used for the SSO login hint and the UI display). Called after
|
||||
// a successful logout so a logged-out profile no longer shows a stale account
|
||||
// email. The state file only stores the email, so deleting it is equivalent to
|
||||
// clearing it; the next SSO login recreates it. A missing file is not an error.
|
||||
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, profileName+".state.json")
|
||||
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
99
client/internal/syncstore/disk.go
Normal file
99
client/internal/syncstore/disk.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package syncstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// syncResponseFileName is the name of the file the sync response is serialized
|
||||
// to, placed inside the configured directory (the state directory).
|
||||
const syncResponseFileName = "networkmap.pb"
|
||||
|
||||
// diskStore serializes the latest sync response to a file on disk instead of
|
||||
// keeping it in memory. This trades disk I/O for a much smaller memory
|
||||
// footprint, which matters on memory-constrained platforms (iOS).
|
||||
type diskStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewDiskStore returns a Store that serializes the sync response to a file in
|
||||
// the given directory. If dir is empty it falls back to the OS temp directory.
|
||||
//
|
||||
// Any file left over from a previous run is removed on construction so a fresh
|
||||
// store never reads stale data (e.g. another profile's network map).
|
||||
func NewDiskStore(dir string) Store {
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
s := &diskStore{
|
||||
path: filepath.Join(dir, syncResponseFileName),
|
||||
}
|
||||
if err := s.Clear(); err != nil {
|
||||
log.Warnf("failed to clear stale sync response file: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
|
||||
if resp == nil {
|
||||
return s.Clear()
|
||||
}
|
||||
|
||||
bs, err := proto.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sync response: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
|
||||
return fmt.Errorf("write sync response to %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
bs, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
resp := &mgmProto.SyncResponse{}
|
||||
if err := proto.Unmarshal(bs, resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal sync response: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *diskStore) Clear() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove sync response file %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
9
client/internal/syncstore/factory_ios.go
Normal file
9
client/internal/syncstore/factory_ios.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build ios
|
||||
|
||||
package syncstore
|
||||
|
||||
// New returns the platform default store. On iOS the sync response is
|
||||
// serialized to disk (in dir) to keep it out of the constrained process memory.
|
||||
func New(dir string) Store {
|
||||
return NewDiskStore(dir)
|
||||
}
|
||||
9
client/internal/syncstore/factory_other.go
Normal file
9
client/internal/syncstore/factory_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !ios
|
||||
|
||||
package syncstore
|
||||
|
||||
// New returns the platform default store. On all non-iOS platforms the sync
|
||||
// response is kept in memory; dir is unused.
|
||||
func New(_ string) Store {
|
||||
return NewMemoryStore()
|
||||
}
|
||||
56
client/internal/syncstore/memory.go
Normal file
56
client/internal/syncstore/memory.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package syncstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// memoryStore keeps the latest sync response in memory.
|
||||
type memoryStore struct {
|
||||
mu sync.RWMutex
|
||||
latest *mgmProto.SyncResponse
|
||||
}
|
||||
|
||||
// NewMemoryStore returns a Store that keeps the sync response in memory.
|
||||
func NewMemoryStore() Store {
|
||||
return &memoryStore{}
|
||||
}
|
||||
|
||||
func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.latest = resp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
|
||||
s.mu.RLock()
|
||||
latest := s.latest
|
||||
s.mu.RUnlock()
|
||||
|
||||
if latest == nil {
|
||||
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest))
|
||||
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("clone sync response")
|
||||
}
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) Clear() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.latest = nil
|
||||
return nil
|
||||
}
|
||||
29
client/internal/syncstore/syncstore.go
Normal file
29
client/internal/syncstore/syncstore.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Package syncstore stores the latest Management sync response (which carries
|
||||
// the network map) for debug bundle generation.
|
||||
//
|
||||
// The storage backend is selected at build time per operating system: on iOS
|
||||
// the response is serialized to disk to keep it out of the (tightly
|
||||
// constrained) process memory, while on all other platforms it is kept in
|
||||
// memory. The backend is chosen by the New constructor; see factory_ios.go and
|
||||
// factory_other.go.
|
||||
package syncstore
|
||||
|
||||
import (
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// Store persists the latest sync response and returns it on demand.
|
||||
//
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Store interface {
|
||||
// Set stores the given sync response, replacing any previously stored one.
|
||||
Set(resp *mgmProto.SyncResponse) error
|
||||
|
||||
// Get returns the stored sync response, or nil if none is stored.
|
||||
// The returned value is an independent copy that the caller may retain.
|
||||
Get() (*mgmProto.SyncResponse, error)
|
||||
|
||||
// Clear removes any stored sync response. It is safe to call when nothing
|
||||
// is stored.
|
||||
Clear() error
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
|
||||
const (
|
||||
latestVersion = "latest"
|
||||
// this version will be ignored
|
||||
developmentVersion = "development"
|
||||
)
|
||||
|
||||
var errNoUpdateState = errors.New("no update state found")
|
||||
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
|
||||
}
|
||||
|
||||
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
|
||||
if m.currentVersion == developmentVersion {
|
||||
if version.IsDevelopmentVersion(m.currentVersion) {
|
||||
log.Debugf("skipping auto-update, running development version")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1638,6 +1638,7 @@ type LocalPeerState struct {
|
||||
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
|
||||
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
|
||||
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
|
||||
WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1728,6 +1729,13 @@ func (x *LocalPeerState) GetIpv6() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LocalPeerState) GetWgPort() int32 {
|
||||
if x != nil {
|
||||
return x.WgPort
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SignalState contains the latest state of a signal connection
|
||||
type SignalState struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2745,6 +2753,7 @@ type DebugBundleRequest struct {
|
||||
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
|
||||
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
|
||||
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
|
||||
CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2807,6 +2816,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebugBundleRequest) GetCliVersion() string {
|
||||
if x != nil {
|
||||
return x.CliVersion
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DebugBundleResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
@@ -6739,7 +6755,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"sshHostKey\x18\x13 \x01(\fR\n" +
|
||||
"sshHostKey\x12\x12\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
|
||||
"\x0eLocalPeerState\x12\x0e\n" +
|
||||
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
|
||||
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
|
||||
@@ -6748,7 +6764,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
|
||||
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
|
||||
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
|
||||
"\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
|
||||
"\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
|
||||
"\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
|
||||
"\vSignalState\x12\x10\n" +
|
||||
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
|
||||
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
|
||||
@@ -6826,14 +6843,17 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
|
||||
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
|
||||
"\x17ForwardingRulesResponse\x12,\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
|
||||
"\x12DebugBundleRequest\x12\x1c\n" +
|
||||
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
|
||||
"\n" +
|
||||
"systemInfo\x18\x03 \x01(\bR\n" +
|
||||
"systemInfo\x12\x1c\n" +
|
||||
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
|
||||
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
|
||||
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
|
||||
"\n" +
|
||||
"cliVersion\x18\x06 \x01(\tR\n" +
|
||||
"cliVersion\"}\n" +
|
||||
"\x13DebugBundleResponse\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
|
||||
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
|
||||
|
||||
@@ -384,6 +384,7 @@ message LocalPeerState {
|
||||
bool rosenpassPermissive = 6;
|
||||
repeated string networks = 7;
|
||||
string ipv6 = 8;
|
||||
int32 wgPort = 9;
|
||||
}
|
||||
|
||||
// SignalState contains the latest state of a signal connection
|
||||
@@ -512,6 +513,7 @@ message DebugBundleRequest {
|
||||
bool systemInfo = 3;
|
||||
string uploadURL = 4;
|
||||
uint32 logFileCount = 5;
|
||||
string cliVersion = 6;
|
||||
}
|
||||
|
||||
message DebugBundleResponse {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if ! which realpath > /dev/null 2>&1
|
||||
then
|
||||
echo realpath is not installed
|
||||
echo run: brew install coreutils
|
||||
exit 1
|
||||
if ! which realpath >/dev/null 2>&1; then
|
||||
echo realpath is not installed
|
||||
echo run: brew install coreutils
|
||||
exit 1
|
||||
fi
|
||||
|
||||
old_pwd=$(pwd)
|
||||
script_path=$(dirname $(realpath "$0"))
|
||||
script_path=$(dirname "$(realpath "$0")")
|
||||
cd "$script_path"
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
|
||||
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
|
||||
cd "$old_pwd"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// DebugBundle creates a debug bundle and returns the location.
|
||||
@@ -70,6 +71,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
CapturePath: capturePath,
|
||||
RefreshStatus: refreshStatus,
|
||||
ClientMetrics: clientMetrics,
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
CliVersion: req.CliVersion,
|
||||
},
|
||||
debug.BundleConfig{
|
||||
Anonymize: req.GetAnonymize(),
|
||||
|
||||
@@ -793,6 +793,22 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// StatusNeedsLogin is a legitimate fresh-start entry state: a successful
|
||||
// WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is
|
||||
// done, the token is in hand, but the engine hasn't been brought up yet —
|
||||
// see WaitSSOLogin's state-transition table). The same holds after a
|
||||
// mid-session expiry tore the engine down (clientRunning == false) and the
|
||||
// user re-authenticated. In both cases the caller's Up is expected to drive
|
||||
// the connection; treat NeedsLogin like Idle and reset to Idle so the
|
||||
// engine's own StatusConnecting → StatusConnected progression starts from a
|
||||
// clean slate. Without this, the first Up after an SSO login fails with
|
||||
// "up already in progress" and the user has to trigger Up a second time
|
||||
// (CLI: re-run `netbird up`; GUI: click Connect again).
|
||||
if status == internal.StatusNeedsLogin {
|
||||
status = internal.StatusIdle
|
||||
state.Set(internal.StatusIdle)
|
||||
}
|
||||
|
||||
if status != internal.StatusIdle {
|
||||
s.mutex.Unlock()
|
||||
return nil, fmt.Errorf("up already in progress: current status %s", status)
|
||||
@@ -1194,7 +1210,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
|
||||
}
|
||||
}()
|
||||
|
||||
return mgmClient.Logout()
|
||||
if err := mgmClient.Logout(); err != nil {
|
||||
// The peer is already gone from the management server (e.g. deleted
|
||||
// from the dashboard). The logout's goal — deregistering this peer —
|
||||
// is therefore already satisfied, so treat NotFound as success rather
|
||||
// than blocking the logout/profile-removal flow.
|
||||
if logoutPeerGone(err) {
|
||||
log.Infof("peer already removed from management server, treating logout as successful")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the daemon status
|
||||
@@ -1848,6 +1876,8 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
|
||||
return nil, fmt.Errorf("failed to create profile: %w", err)
|
||||
}
|
||||
|
||||
s.publishProfileListChanged(msg.ProfileName)
|
||||
|
||||
return &proto.AddProfileResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -1869,9 +1899,32 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
return nil, fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
s.publishProfileListChanged(msg.ProfileName)
|
||||
|
||||
return &proto.RemoveProfileResponse{}, nil
|
||||
}
|
||||
|
||||
// publishProfileListChanged nudges the desktop UI to refresh its profile list
|
||||
// after a CLI-driven add/remove. The daemon exposes no dedicated
|
||||
// profile-changed RPC event, and a profile add/remove doesn't move the
|
||||
// connection status, so the UI's SubscribeStatus path never fires for it (and
|
||||
// the tray's status-string guard would swallow it anyway). Instead we publish
|
||||
// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
|
||||
// recognises the metadata "kind" marker and translates it into its internal
|
||||
// profile-changed signal that both the tray menu and the React profile views
|
||||
// already subscribe to (see client/ui/services/daemon_feed.go,
|
||||
// MetadataKindProfileListChanged). userMessage is intentionally empty so this
|
||||
// stays a silent refresh signal rather than a user-facing notification.
|
||||
func (s *Server) publishProfileListChanged(profileName string) {
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"Profile list changed",
|
||||
"",
|
||||
map[string]string{"kind": "profile-list-changed", "profile": profileName},
|
||||
)
|
||||
}
|
||||
|
||||
// ListProfiles lists all profiles in the daemon.
|
||||
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
|
||||
s.mutex.Lock()
|
||||
@@ -2076,3 +2129,15 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logoutPeerGone reports whether a management Logout failed because the peer
|
||||
// no longer exists server-side (gRPC NotFound), walking the wrap chain since
|
||||
// the client wraps the gRPC status with fmt.Errorf.
|
||||
func logoutPeerGone(err error) bool {
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ type OutputOverview struct {
|
||||
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
|
||||
PubKey string `json:"publicKey" yaml:"publicKey"`
|
||||
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
|
||||
WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
|
||||
FQDN string `json:"fqdn" yaml:"fqdn"`
|
||||
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
|
||||
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
|
||||
@@ -196,6 +197,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
|
||||
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
|
||||
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
|
||||
WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
|
||||
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
|
||||
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
|
||||
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
|
||||
@@ -569,6 +571,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
|
||||
}
|
||||
|
||||
daemonVersion := "N/A"
|
||||
if o.DaemonVersion != "" {
|
||||
daemonVersion = o.DaemonVersion
|
||||
}
|
||||
|
||||
cliVersion := version.NetbirdVersion()
|
||||
if o.CliVersion != "" {
|
||||
cliVersion = o.CliVersion
|
||||
}
|
||||
|
||||
wgPortString := "N/A"
|
||||
if o.WgPort > 0 {
|
||||
wgPortString = fmt.Sprintf("%d", o.WgPort)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf(
|
||||
"OS: %s\n"+
|
||||
"Daemon version: %s\n"+
|
||||
@@ -582,6 +599,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"NetBird IP: %s\n"+
|
||||
"%s"+
|
||||
"Interface type: %s\n"+
|
||||
"Wireguard port: %s\n"+
|
||||
"Quantum resistance: %s\n"+
|
||||
"Lazy connection: %s\n"+
|
||||
"SSH Server: %s\n"+
|
||||
@@ -590,8 +608,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"%s"+
|
||||
"Peers count: %s\n",
|
||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||
o.DaemonVersion,
|
||||
version.NetbirdVersion(),
|
||||
daemonVersion,
|
||||
cliVersion,
|
||||
o.ProfileName,
|
||||
managementConnString,
|
||||
signalConnString,
|
||||
@@ -601,6 +619,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
interfaceIP,
|
||||
ipv6Line,
|
||||
interfaceTypeString,
|
||||
wgPortString,
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
|
||||
@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
|
||||
Ipv6: "fd00::100",
|
||||
PubKey: "Some-Pub-Key",
|
||||
KernelInterface: true,
|
||||
WgPort: 51820,
|
||||
Fqdn: "some-localhost.awesome-domain.com",
|
||||
Networks: []string{
|
||||
"10.10.0.0/24",
|
||||
@@ -210,6 +211,7 @@ var overview = OutputOverview{
|
||||
IPv6: "fd00::100",
|
||||
PubKey: "Some-Pub-Key",
|
||||
KernelInterface: true,
|
||||
WgPort: 51820,
|
||||
FQDN: "some-localhost.awesome-domain.com",
|
||||
NSServerGroups: []NsServerGroupStateOutput{
|
||||
{
|
||||
@@ -369,6 +371,7 @@ func TestParsingToJSON(t *testing.T) {
|
||||
"netbirdIpv6": "fd00::100",
|
||||
"publicKey": "Some-Pub-Key",
|
||||
"usesKernelInterface": true,
|
||||
"wireguardPort": 51820,
|
||||
"fqdn": "some-localhost.awesome-domain.com",
|
||||
"quantumResistance": false,
|
||||
"quantumResistancePermissive": false,
|
||||
@@ -487,6 +490,7 @@ netbirdIp: 192.168.178.100/16
|
||||
netbirdIpv6: fd00::100
|
||||
publicKey: Some-Pub-Key
|
||||
usesKernelInterface: true
|
||||
wireguardPort: 51820
|
||||
fqdn: some-localhost.awesome-domain.com
|
||||
quantumResistance: false
|
||||
quantumResistancePermissive: false
|
||||
@@ -579,12 +583,13 @@ FQDN: some-localhost.awesome-domain.com
|
||||
NetBird IP: 192.168.178.100/16
|
||||
NetBird IPv6: fd00::100
|
||||
Interface type: Kernel
|
||||
Wireguard port: %d
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
|
||||
|
||||
assert.Equal(t, expectedDetail, detail)
|
||||
}
|
||||
@@ -604,6 +609,7 @@ FQDN: some-localhost.awesome-domain.com
|
||||
NetBird IP: 192.168.178.100/16
|
||||
NetBird IPv6: fd00::100
|
||||
Interface type: Kernel
|
||||
Wireguard port: 51820
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
|
||||
@@ -9,7 +9,8 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
|
||||
### Go (top-level package `main`)
|
||||
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
|
||||
- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
|
||||
- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||
- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` → `fillExitNodeSubmenu`), daemon-version row (`tray_status.go`), and the About → Update row (`tray_update.go applyState` → `onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
|
||||
- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked.
|
||||
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`.
|
||||
- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
|
||||
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
|
||||
@@ -36,9 +37,9 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
|
||||
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
|
||||
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
|
||||
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
|
||||
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
|
||||
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
|
||||
| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. |
|
||||
|
||||
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
|
||||
@@ -95,6 +96,7 @@ The main window is created up front in `main.go`. Auxiliary windows are created
|
||||
- **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
|
||||
- **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow.Currently no triggers wired — daemon-status integration is a follow-up.
|
||||
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
|
||||
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
|
||||
|
||||
The four lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
|
||||
|
||||
@@ -147,6 +149,8 @@ The tray uses Wails' built-in `notifications` service. One `notifications.Notifi
|
||||
|
||||
`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout".
|
||||
|
||||
Both `windows:build` and `windows:build:console` (the latter outputs `bin/netbird-ui-console.exe` linked against the console subsystem, so Go stdout/stderr/logrus print to the launching terminal) honour `DEV=true`, which drops the `-tags production` flag. The `production` tag is what disables the WebKit/WebView2 DevTools inspector — so `DEV=true` is the only way to get a Windows binary where the frontend JS console is reachable (right-click → Inspect / F12). Cross-compile from Linux with `CGO_ENABLED=1 task windows:build:console DEV=true`.
|
||||
|
||||
## Useful references
|
||||
- `WAILS-DIALOGS.md` (sibling) — full `@wailsio/runtime` `Dialogs` API + per-OS behaviour + frameless-window pattern.
|
||||
- `LINUX-TRAY.md` (sibling) — StatusNotifierWatcher + XEmbed host details.
|
||||
|
||||
@@ -5,4 +5,5 @@ Icon=netbird
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
Keywords=netbird;
|
||||
Keywords=netbird;
|
||||
StartupWMClass=org.wails.netbird
|
||||
@@ -41,6 +41,11 @@ tasks:
|
||||
|
||||
Cross-compile from Linux works the same way:
|
||||
CGO_ENABLED=1 task windows:build:console
|
||||
|
||||
Pass DEV=true to drop the `production` build tag so the WebKit/WebView2
|
||||
DevTools inspector (right-click → Inspect, or F12) stays enabled and the
|
||||
frontend JS console is reachable — same DEV handling as windows:build:
|
||||
CGO_ENABLED=1 task windows:build:console DEV=true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
@@ -61,9 +66,11 @@ tasks:
|
||||
- cmd: rm -f *.syso
|
||||
platforms: [linux, darwin]
|
||||
vars:
|
||||
# Identical to build:native's flags except no -H windowsgui, so the
|
||||
# binary attaches to the launching console.
|
||||
BUILD_FLAGS: '-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"'
|
||||
# Identical to build:native's flags (including DEV handling) except no
|
||||
# -H windowsgui, so the binary attaches to the launching console. With
|
||||
# DEV=true the `production` tag is dropped, keeping the WebKit/WebView2
|
||||
# DevTools inspector enabled so the frontend JS console is reachable.
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
|
||||
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
|
||||
env:
|
||||
|
||||
@@ -27,12 +27,13 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). |
|
||||
| `/dialog/session-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
|
||||
| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
|
||||
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
|
||||
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
In `app.tsx` the four dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
|
||||
|
||||
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
|
||||
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. `DialogProvider` is outermost (and outside the daemon-availability gate) so `useConfirm()` works everywhere regardless of daemon state. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
|
||||
|
||||
Page-specific chrome lives next to the page, not in the layout:
|
||||
- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only:
|
||||
@@ -53,21 +54,22 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window).
|
||||
- `modules/session/` — `SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
|
||||
- `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
|
||||
- `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`.
|
||||
- `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal.
|
||||
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
|
||||
|
||||
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
|
||||
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `components/` — presentational primitives, no domain coupling. Grouped by family:
|
||||
- `components/buttons/` — `Button`, `IconButton`.
|
||||
- `components/inputs/` — `Input`, `SearchInput`.
|
||||
- `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`.
|
||||
- `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly).
|
||||
- `components/switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
|
||||
- `components/typography/` — `Label`, `HelpText`.
|
||||
- `components/empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`.
|
||||
- Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives).
|
||||
- `layouts/` — `AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`.
|
||||
- `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`).
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`.
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller.
|
||||
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
|
||||
|
||||
## Wails event bus
|
||||
@@ -180,7 +182,7 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
|
||||
|
||||
**Always go through `src/lib/dialogs.ts`** — `errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime and re-enables it on dismissal; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged.
|
||||
|
||||
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `warningDialog` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
|
||||
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). For **confirmations inside an app window** (the polished surfaces), prefer the in-app `useConfirm()` from `contexts/DialogContext.tsx` over the native `warningDialog` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). Reserve the native `warningDialog` (compare against the **Label string**, not an index) for confirmations raised outside a normal app window (tray-driven flows, etc.). **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
|
||||
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
|
||||
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
|
||||
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
|
||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { MainPage } from "@/modules/main/MainPage.tsx";
|
||||
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
|
||||
@@ -39,6 +40,7 @@ Promise.all([
|
||||
<Route path="install-progress" element={<UpdateInProgressDialog />} />
|
||||
<Route path="session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
<Route path="welcome" element={<WelcomeDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 512 512">
|
||||
<path fill="#ffce00" d="M0 341.3h512V512H0z"/>
|
||||
<path fill="#000001" d="M0 0h512v170.7H0z"/>
|
||||
<path fill="red" d="M0 170.7h512v170.6H0z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 232 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 512 512">
|
||||
<path fill="#012169" d="M0 0h512v512H0z"/>
|
||||
<path fill="#FFF" d="M512 0v64L322 256l190 187v69h-67L254 324 68 512H0v-68l186-187L0 74V0h62l192 188L440 0z"/>
|
||||
<path fill="#C8102E" d="m184 324 11 34L42 512H0v-3zm124-12 54 8 150 147v45zM512 0 320 196l-4-44L466 0zM0 1l193 189-59-8L0 49z"/>
|
||||
<path fill="#FFF" d="M176 0v512h160V0zM0 176v160h512V176z"/>
|
||||
<path fill="#C8102E" d="M0 208v96h512v-96zM208 0v512h96V0z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 505 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-hu" viewBox="0 0 512 512">
|
||||
<g fill-rule="evenodd">
|
||||
<path fill="#fff" d="M512 512H0V0h512z"/>
|
||||
<path fill="#388d00" d="M512 512H0V341.3h512z"/>
|
||||
<path fill="#d43516" d="M512 170.8H0V.1h512z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
BIN
client/ui/frontend/src/assets/img/tray-darwin.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-darwin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
client/ui/frontend/src/assets/img/tray-linux.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-linux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
client/ui/frontend/src/assets/img/tray-windows.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-windows.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -4,7 +4,7 @@ import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CheckIcon, ChevronDown, Search } from "lucide-react";
|
||||
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
@@ -13,43 +13,17 @@ import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
|
||||
// build time; the JS bundle only holds URL refs, not the SVG bytes.
|
||||
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?url",
|
||||
});
|
||||
// Intentionally no flag icons here: flags represent countries, not
|
||||
// languages (German is spoken across DE/AT/CH; English across US/UK/AU/
|
||||
// etc.). Each label shows the endonym followed by the englishName in
|
||||
// parentheses when the two differ (e.g. "Deutsch (German)"), in both
|
||||
// the trigger and the dropdown rows.
|
||||
// See: https://www.flagsarenotlanguages.com/blog/
|
||||
|
||||
const flagByCode: Record<string, string> = {};
|
||||
for (const path in FLAG_URLS) {
|
||||
const match = path.match(/1x1\/([^/]+)\.svg$/);
|
||||
if (match) flagByCode[match[1]] = FLAG_URLS[path];
|
||||
}
|
||||
|
||||
const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]];
|
||||
|
||||
function Flag({ code, label }: { code: string; label: string }) {
|
||||
const src = flagFor(code);
|
||||
if (!src) {
|
||||
return (
|
||||
<span
|
||||
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const labelFor = (lang: Language): string =>
|
||||
lang.englishName && lang.englishName !== lang.displayName
|
||||
? `${lang.displayName} (${lang.englishName})`
|
||||
: lang.displayName;
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -121,9 +95,9 @@ export function LanguagePicker() {
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{current && <Flag code={current.code} label={current.displayName} />}
|
||||
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
|
||||
<span className={"truncate flex-1 text-left"}>
|
||||
{current?.displayName ?? "—"}
|
||||
{current ? labelFor(current) : "—"}
|
||||
</span>
|
||||
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
@@ -193,12 +167,8 @@ export function LanguagePicker() {
|
||||
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
|
||||
)}
|
||||
>
|
||||
<Flag
|
||||
code={lang.code}
|
||||
label={lang.displayName}
|
||||
/>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{lang.displayName}
|
||||
<span className={"flex-1 min-w-0 truncate"}>
|
||||
{labelFor(lang)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -7,21 +7,28 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
|
||||
type Props = {
|
||||
value: ManagementMode;
|
||||
onChange: (mode: ManagementMode) => void;
|
||||
// fullWidth stretches the segmented control to fill its container —
|
||||
// the SettingsGeneral row uses the default (shrink-to-content) layout,
|
||||
// the welcome dialog asks for the wide variant so the picker spans the
|
||||
// narrow dialog width.
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||
export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const itemClass = fullWidth ? "flex-1" : undefined;
|
||||
return (
|
||||
<SwitchItemGroup
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as ManagementMode)}
|
||||
className={fullWidth ? "w-full" : undefined}
|
||||
>
|
||||
<SwitchItem value={ManagementMode.Cloud}>
|
||||
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>
|
||||
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
||||
{t("settings.general.management.selfHosted")}
|
||||
</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
|
||||
@@ -3,18 +3,36 @@ import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
|
||||
// (ConfirmDialog, etc.). Renders a bordered dark tile with the provided
|
||||
// lucide icon centered inside.
|
||||
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
|
||||
// icon centered inside. The `tone` selects the semantic colour scheme —
|
||||
// `default` keeps the neutral dark tile; info/warning/danger tint the tile,
|
||||
// border and icon to match the action's severity.
|
||||
export type SquareIconTone = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const toneClass: Record<SquareIconTone, string> = {
|
||||
default: "bg-nb-gray-920 border-nb-gray-900 text-white",
|
||||
info: "bg-sky-950 border-sky-500 text-sky-100",
|
||||
warning: "bg-netbird-950 border-netbird text-netbird",
|
||||
danger: "bg-red-950 border-red-500 text-red-500",
|
||||
};
|
||||
|
||||
type SquareIconProps = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
tone?: SquareIconTone;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
|
||||
export const SquareIcon = ({
|
||||
icon: Icon,
|
||||
iconSize = 20,
|
||||
tone = "default",
|
||||
className,
|
||||
}: SquareIconProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center border",
|
||||
toneClass[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import * as RTooltip from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -9,8 +9,15 @@ type Props = {
|
||||
align?: RTooltip.TooltipContentProps["align"];
|
||||
delayDuration?: number;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
interactive?: boolean;
|
||||
keepOpenOnClick?: boolean;
|
||||
// Overrides the default tooltip-content chrome (background, padding,
|
||||
// border, radius). Use when a richer body needs popover-style layout.
|
||||
contentClassName?: string;
|
||||
// Ms to wait after pointer-leave before closing. Lets the user cross
|
||||
// a gap between trigger and content without the tooltip snapping shut.
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -20,14 +27,35 @@ export const Tooltip = ({
|
||||
align = "center",
|
||||
delayDuration = 200,
|
||||
sideOffset = 6,
|
||||
alignOffset = 0,
|
||||
interactive = false,
|
||||
keepOpenOnClick = true,
|
||||
contentClassName,
|
||||
closeDelay = 0,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hoveringRef = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
if (closeDelay <= 0) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next && keepOpenOnClick && hoveringRef.current) return;
|
||||
if (next) cancelClose();
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
@@ -41,10 +69,11 @@ export const Tooltip = ({
|
||||
asChild
|
||||
onPointerEnter={() => {
|
||||
hoveringRef.current = true;
|
||||
cancelClose();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoveringRef.current = false;
|
||||
setOpen(false);
|
||||
scheduleClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -54,15 +83,19 @@ export const Tooltip = ({
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
onPointerEnter={interactive ? cancelClose : undefined}
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={
|
||||
interactive ? undefined : (e) => e.preventDefault()
|
||||
}
|
||||
className={cn(
|
||||
"z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
"text-xs text-nb-gray-100 shadow-lg",
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
|
||||
!interactive && "pointer-events-none",
|
||||
contentClassName ??
|
||||
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
|
||||
42
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
42
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
tooltipContent?: ReactNode;
|
||||
delayDuration?: number;
|
||||
};
|
||||
|
||||
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
|
||||
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
|
||||
// the "tooltip on hover even though everything fits" annoyance. The caller
|
||||
// supplies the wrapper styling (font, max-width, etc.) via className — this
|
||||
// component only owns the truncate + measure + tooltip behavior.
|
||||
export const TruncatedText = ({
|
||||
text,
|
||||
className,
|
||||
tooltipContent,
|
||||
delayDuration = 600,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [text]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
|
||||
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -10,12 +10,16 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
copy?: string;
|
||||
// When true, the content is replaced by a centered spinner while keeping
|
||||
// the button's rendered width/height (the content stays in the layout,
|
||||
// just hidden). Also disables the button.
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"relative",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none cursor-default",
|
||||
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
|
||||
],
|
||||
@@ -93,7 +97,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
xs: "text-xs py-2.5 px-3.5",
|
||||
xs2: "text-[0.78rem] py-2 px-4",
|
||||
xs2: "text-[0.78rem] py-[1.1rem] px-4 leading-[0]",
|
||||
sm: "text-sm py-[9px] px-4",
|
||||
md: "py-[9px] px-4",
|
||||
lg: "text-lg py-[9px] px-4",
|
||||
@@ -124,6 +128,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
onClick,
|
||||
disabled,
|
||||
copy,
|
||||
loading = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -134,7 +139,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant,
|
||||
@@ -159,8 +164,16 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||
{children}
|
||||
{loading && (
|
||||
<span className={"absolute inset-0 flex items-center justify-center"}>
|
||||
<Loader2 size={iconSize} className={"animate-spin"} />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("contents", loading && "invisible")}>
|
||||
{copy !== undefined &&
|
||||
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(func
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-5 text-center px-8 py-6",
|
||||
"flex flex-col items-center gap-5 text-center px-8 pt-6 pb-7",
|
||||
isMacOS() && "pt-10",
|
||||
)}
|
||||
>
|
||||
|
||||
110
client/ui/frontend/src/components/dialog/ConfirmModal.tsx
Normal file
110
client/ui/frontend/src/components/dialog/ConfirmModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
|
||||
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
|
||||
// title + (optionally multi-line) description with Cancel / confirm buttons
|
||||
// in the footer. It's the in-window counterpart to the native warningDialog.
|
||||
//
|
||||
// Most call sites should not render this directly: use the imperative
|
||||
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
|
||||
// single instance at the provider level. Render ConfirmModal yourself only
|
||||
// when you need bespoke control over its open/busy lifecycle.
|
||||
type ConfirmModalProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
/** Confirm button label. */
|
||||
confirmLabel: string;
|
||||
/** Cancel button label; defaults to the shared "Cancel" string. */
|
||||
cancelLabel?: string;
|
||||
/** Use the destructive (red) confirm button variant. */
|
||||
danger?: boolean;
|
||||
/** Disable the buttons (and ignore dismiss) while an action runs. */
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const ConfirmModal = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Retain the last shown content so it stays rendered through Radix's
|
||||
// close animation instead of blanking out the instant the caller clears
|
||||
// its state on close.
|
||||
type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & {
|
||||
cancelLabel: string;
|
||||
};
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const resolvedCancel = cancelLabel ?? t("common.cancel");
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSnapshot({ title, description, confirmLabel, cancelLabel: resolvedCancel, danger });
|
||||
}
|
||||
}, [open, title, description, confirmLabel, resolvedCancel, danger]);
|
||||
|
||||
const view = open
|
||||
? { title, description, confirmLabel, cancelLabel: resolvedCancel, danger }
|
||||
: snapshot;
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && !busy) onCancel();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content
|
||||
maxWidthClass="max-w-sm"
|
||||
showClose={false}
|
||||
className="py-5"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
<div className="flex flex-col gap-5 px-5">
|
||||
<div className="flex flex-col gap-1 pl-1">
|
||||
<DialogHeading align={"left"}>{view.title}</DialogHeading>
|
||||
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
|
||||
{view.description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions className={"flex-row justify-end gap-2.5"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs2"}
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{view.cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={view.danger ? "danger" : "primary"}
|
||||
size={"xs2"}
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{view.confirmLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const Root = DialogPrimitive.Root;
|
||||
@@ -15,7 +16,7 @@ const Overlay = forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
|
||||
"bg-black/40 backdrop-blur-sm",
|
||||
"bg-black/60",
|
||||
"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",
|
||||
@@ -36,6 +37,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
{ className, children, showClose = true, maxWidthClass = "max-w-md", ...props },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<Overlay>
|
||||
@@ -67,7 +69,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
"text-nb-gray-300 hover:text-nb-gray-100",
|
||||
"focus:outline-none disabled:pointer-events-none",
|
||||
)}
|
||||
aria-label="Close"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -3,11 +3,32 @@ import { cn } from "@/lib/cn";
|
||||
|
||||
// DialogDescription is the supporting description text rendered under a
|
||||
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
type DialogDescriptionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
};
|
||||
|
||||
export const DialogDescription = ({ children, className }: DialogDescriptionProps) => (
|
||||
<p className={cn("text-sm text-nb-gray-300 select-none", className)}>{children}</p>
|
||||
export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => (
|
||||
// w-full for the same reason DialogHeading carries it — see the
|
||||
// comment there. The default text-center remains visually identical
|
||||
// to before; left/right alignment now anchors to the dialog content
|
||||
// edge instead of collapsing to no-op on a content-width box.
|
||||
<p
|
||||
className={cn(
|
||||
"w-full text-sm text-nb-gray-300 select-none",
|
||||
alignClass[align],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,34 @@ import { cn } from "@/lib/cn";
|
||||
// DialogHeading is the title text used inside ConfirmDialog (and any other
|
||||
// dialog-style surface with the same shape). Pair with DialogDescription
|
||||
// for the standard title/description stack.
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
type DialogHeadingProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
};
|
||||
|
||||
export const DialogHeading = ({ children, className }: DialogHeadingProps) => (
|
||||
<p className={cn("text-base font-semibold text-nb-gray-50 select-none", className)}>
|
||||
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
|
||||
// w-full so the alignClass actually has a box to anchor against.
|
||||
// The wrapping <p> defaulted to content width inside a flex column,
|
||||
// which made `text-left` a no-op (nothing to push the text away
|
||||
// from). Stretching the element is invisible for the default
|
||||
// text-center case (center of content == center of box) and lets
|
||||
// text-left/right line up with the dialog's content edge.
|
||||
<p
|
||||
className={cn(
|
||||
"w-full text-base font-semibold text-nb-gray-50 select-none",
|
||||
alignClass[align],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -31,11 +31,11 @@ export const EmptyState = ({
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center max-w-sm mx-auto relative top-7"
|
||||
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-6"
|
||||
}
|
||||
>
|
||||
<SquareIcon icon={icon} className={"mb-3"} />
|
||||
<p className={"text-base font-medium text-nb-gray-200 mb-1"}>{title}</p>
|
||||
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
|
||||
{description && <p className={"text-sm text-nb-gray-350"}>{description}</p>}
|
||||
{learnMoreUrl && learnMoreTopic && (
|
||||
<p className={"text-sm text-nb-gray-350"}>
|
||||
|
||||
@@ -16,7 +16,7 @@ export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) =>
|
||||
icon={icon}
|
||||
title={title ?? t("common.noResults.title")}
|
||||
description={description ?? t("common.noResults.description")}
|
||||
className={"relative -top-3.5"}
|
||||
className={"relative top-[2.8rem]"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export const NotConnectedState = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-full min-h-[260px] flex-1 flex items-center justify-center px-6 pb-20 top-1 relative"
|
||||
"h-full flex-1 flex items-start justify-center pt-36 top-[0.6rem] px-6 relative"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
|
||||
@@ -13,6 +14,10 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
|
||||
maxWidthClass?: string;
|
||||
icon?: ReactNode;
|
||||
error?: string;
|
||||
// A soft, non-blocking caveat rendered in orange (vs. error's red). Used
|
||||
// e.g. for "couldn't reach this server" where the value is syntactically
|
||||
// fine and the user may still proceed. `error` takes precedence.
|
||||
warning?: string;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
copy?: boolean;
|
||||
@@ -33,6 +38,10 @@ const inputVariants = cva("", {
|
||||
"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",
|
||||
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-orange-400 text-orange-400",
|
||||
"ring-offset-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10 focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
prefixSuffixVariant: {
|
||||
default: [
|
||||
@@ -53,6 +62,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
warning,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
@@ -62,6 +72,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
@@ -103,7 +114,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="hover:text-white transition-all pointer-events-auto"
|
||||
aria-label="Toggle password visibility"
|
||||
aria-label={t("common.togglePasswordVisibility")}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
@@ -126,7 +137,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="hover:text-white transition-all pointer-events-auto"
|
||||
aria-label="Copy"
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
@@ -174,7 +185,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? "error" : variant,
|
||||
variant: error ? "error" : warning ? "warning" : variant,
|
||||
}),
|
||||
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
@@ -217,7 +228,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Increase"
|
||||
aria-label={t("common.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"
|
||||
>
|
||||
@@ -226,7 +237,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Decrease"
|
||||
aria-label={t("common.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",
|
||||
@@ -238,9 +249,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
|
||||
{error}
|
||||
{(error || warning) && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs mt-2 inline-flex items-center gap-1",
|
||||
error ? "text-red-500" : "text-orange-400",
|
||||
)}
|
||||
>
|
||||
{error ?? warning}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
74
client/ui/frontend/src/contexts/DialogContext.tsx
Normal file
74
client/ui/frontend/src/contexts/DialogContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
|
||||
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
|
||||
|
||||
// DialogContext exposes an imperative `confirm(...)` that resolves to a
|
||||
// boolean — the in-app equivalent of the native warningDialog promise. The
|
||||
// single <ConfirmModal/> lives here at the provider level, so call sites
|
||||
// just `await confirm({...})` instead of each wiring up their own modal
|
||||
// component + open/busy state.
|
||||
//
|
||||
// const confirm = useConfirm();
|
||||
// if (await confirm({ title, description, confirmLabel })) { …do it… }
|
||||
//
|
||||
// Mounted once (outermost in AppLayout) so it's available in every in-window
|
||||
// route across both the main and settings windows.
|
||||
export type ConfirmOptions = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
|
||||
cancelLabel?: string;
|
||||
/** Use the destructive (red) confirm button variant. */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
type DialogContextValue = {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolverRef = useRef<((result: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions) => {
|
||||
setOptions(opts);
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Resolve the pending promise and start the close animation. The options
|
||||
// stay in state so ConfirmModal still has content to render while it
|
||||
// animates out.
|
||||
const settle = (result: boolean) => {
|
||||
resolverRef.current?.(result);
|
||||
resolverRef.current = null;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={options?.title ?? ""}
|
||||
description={options?.description ?? ""}
|
||||
confirmLabel={options?.confirmLabel ?? ""}
|
||||
cancelLabel={options?.cancelLabel}
|
||||
danger={options?.danger}
|
||||
onConfirm={() => settle(true)}
|
||||
onCancel={() => settle(false)}
|
||||
/>
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useConfirm = () => {
|
||||
const ctx = useContext(DialogContext);
|
||||
if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
|
||||
return ctx.confirm;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks" | "exitNode";
|
||||
export type NavSection = "peers" | "networks";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
|
||||
@@ -22,7 +22,16 @@ import i18next from "@/lib/i18n";
|
||||
// the observer can settle on a stale size before React's commit and the
|
||||
// font's glyph metrics finish updating. An explicit double-rAF after the
|
||||
// language flip guarantees the final layout is the one we measure.
|
||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
|
||||
//
|
||||
// `ready` (default true) gates Window.SetSize + Window.Show. Pass false
|
||||
// while the caller is still resolving its initial content (e.g. waiting
|
||||
// on an async probe) so the window stays Hidden instead of briefly
|
||||
// rendering placeholder padding at the wrong size — Linux/GNOME in
|
||||
// particular paints whatever the frame ends up at, and a transient
|
||||
// half-height frame can leak through. Flip ready=true once the real
|
||||
// content is in the DOM; the effect re-runs, measures the final size,
|
||||
// and shows the window.
|
||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
|
||||
const ref = useRef<T | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -31,6 +40,7 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
const apply = () => {
|
||||
if (!ready) return;
|
||||
const h = Math.ceil(el.getBoundingClientRect().height);
|
||||
if (h <= 0) return;
|
||||
// Wails Window.SetSize takes the *frame* size on every platform
|
||||
@@ -79,6 +89,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
|
||||
cancelAnimationFrame(raf2);
|
||||
i18next.off("languageChanged", scheduleApply);
|
||||
};
|
||||
}, [width]);
|
||||
}, [width, ready]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { warningDialog } from "@/lib/dialogs.ts";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
import { useConfirm } from "@/contexts/DialogContext.tsx";
|
||||
|
||||
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(
|
||||
// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment].
|
||||
// Host is domain, localhost, or IPv4. Used for syntactic validation only —
|
||||
// reachability is checked separately via checkManagementUrlReachable.
|
||||
export 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}))" +
|
||||
@@ -27,17 +18,67 @@ const URL_PATTERN = new RegExp(
|
||||
"i",
|
||||
);
|
||||
|
||||
function isValidManagementUrl(input: string): boolean {
|
||||
// normalizeManagementUrl prefixes an https:// scheme when the user omits
|
||||
// it. Empty input stays empty.
|
||||
export function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not
|
||||
// touch the network.
|
||||
export function isValidManagementUrl(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
return URL_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
// isCloudManagementUrl reports whether the stored URL is the NetBird
|
||||
// Cloud default (or an empty/unset URL, which the daemon also treats as
|
||||
// cloud-defaulting on first boot).
|
||||
export function isCloudManagementUrl(url: string): boolean {
|
||||
if (!url || url.trim() === "") return true;
|
||||
return url === CLOUD_MANAGEMENT_URL;
|
||||
}
|
||||
|
||||
// checkManagementUrlReachable does a best-effort no-cors GET against the
|
||||
// URL with a short timeout. A resolved fetch (even opaque) means DNS +
|
||||
// TCP + TLS landed; any rejection (network error, DNS, abort) is treated
|
||||
// as unreachable. Self-hosted deployments behind internal-only DNS or
|
||||
// with self-signed certs may return false positives — callers should
|
||||
// surface this as a soft warning, not a hard block.
|
||||
export async function checkManagementUrlReachable(
|
||||
url: string,
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<boolean> {
|
||||
const target = normalizeManagementUrl(url);
|
||||
if (!target) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
function modeFromUrl(url: string): ManagementMode {
|
||||
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
|
||||
}
|
||||
|
||||
export function useManagementUrl() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { config, saveField } = useSettings();
|
||||
const [mode, setModeState] = useState<ManagementMode>(
|
||||
modeFromUrl(config.managementUrl),
|
||||
@@ -45,11 +86,11 @@ export function useManagementUrl() {
|
||||
const [url, setUrl] = useState(
|
||||
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||
);
|
||||
// Guard against double-showing the cloud-switch confirmation when the
|
||||
// user toggles the segmented control multiple times before the prior
|
||||
// Dialogs.Warning promise resolves. Without it each click queues a
|
||||
// fresh native dialog and the user sees them stack up.
|
||||
const switchConfirmOpenRef = useRef(false);
|
||||
// Self-hosted reachability soft-check, mirrored from the onboarding /
|
||||
// profile-creation flows: a failed probe is a non-blocking orange warning,
|
||||
// and a second Save with the same URL goes through regardless.
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setModeState(modeFromUrl(config.managementUrl));
|
||||
@@ -58,34 +99,27 @@ export function useManagementUrl() {
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
const setMode = (next: ManagementMode) => {
|
||||
// Clear the stale warning whenever the target changes.
|
||||
useEffect(() => {
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const setMode = async (next: ManagementMode) => {
|
||||
if (
|
||||
next === ManagementMode.Cloud &&
|
||||
config.managementUrl !== CLOUD_MANAGEMENT_URL
|
||||
) {
|
||||
// Switching from a self-hosted management server to NetBird Cloud
|
||||
// re-points the client at a different deployment and forces a
|
||||
// reconnect/re-login. Confirm before applying.
|
||||
if (switchConfirmOpenRef.current) return;
|
||||
switchConfirmOpenRef.current = true;
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
|
||||
void warningDialog({
|
||||
Title: i18next.t("settings.general.management.switchCloudTitle"),
|
||||
Message: i18next.t("settings.general.management.switchCloudMessage"),
|
||||
Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true, IsDefault: true },
|
||||
{ Label: confirmLabel },
|
||||
],
|
||||
})
|
||||
.then((result) => {
|
||||
if (result !== confirmLabel) return;
|
||||
setModeState(ManagementMode.Cloud);
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
})
|
||||
.finally(() => {
|
||||
switchConfirmOpenRef.current = false;
|
||||
});
|
||||
// reconnect/re-login. Confirm via the in-app modal before applying.
|
||||
const ok = await confirm({
|
||||
title: t("settings.general.management.switchCloudTitle"),
|
||||
description: t("settings.general.management.switchCloudMessage"),
|
||||
confirmLabel: t("settings.general.management.switchCloudConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setModeState(ManagementMode.Cloud);
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
return;
|
||||
}
|
||||
setModeState(next);
|
||||
@@ -101,7 +135,22 @@ export function useManagementUrl() {
|
||||
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
const save = () => saveField("managementUrl", targetUrl);
|
||||
const save = async () => {
|
||||
// Self-hosted: probe the server first. A failed probe surfaces a soft
|
||||
// warning and bails; a second Save (unreachable already set) skips the
|
||||
// re-check and saves anyway, so the user can override a false negative.
|
||||
if (mode === ManagementMode.SelfHosted && !unreachable) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(targetUrl);
|
||||
setChecking(false);
|
||||
if (!reachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveField("managementUrl", targetUrl);
|
||||
setUnreachable(false);
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
@@ -112,5 +161,7 @@ export function useManagementUrl() {
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
|
||||
import { StatusProvider } from "@/contexts/StatusContext.tsx";
|
||||
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
|
||||
import { DialogProvider } from "@/contexts/DialogContext.tsx";
|
||||
|
||||
// Shared shell for every in-window route (main + settings). Owns the daemon-
|
||||
// availability gate (via StatusProvider) and the providers every page needs.
|
||||
@@ -14,15 +15,17 @@ import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
</StatusProvider>
|
||||
<DialogProvider>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
</StatusProvider>
|
||||
</DialogProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
children: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
overlayOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// iOS-style push transition: incoming pane slides in from the right while
|
||||
@@ -16,13 +17,14 @@ const PANEL_TRANSITION = {
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-4",
|
||||
"wails-no-draggable relative m-5",
|
||||
"bg-nb-gray-940 border border-nb-gray-920",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
@@ -29,17 +29,21 @@ for (const path in bundleModules) {
|
||||
}
|
||||
|
||||
// detectBrowserLanguage walks navigator.language + navigator.languages
|
||||
// and returns the first base code ("de" from "de-DE") that has a shipped
|
||||
// bundle. Returns null when none match, so the caller can fall back to
|
||||
// English. We only ever match against the lowercased base — region tags
|
||||
// don't have separate bundles today.
|
||||
// and returns the first shipped bundle that matches. We try an exact
|
||||
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
|
||||
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
|
||||
// null when nothing matches, so the caller can fall back to English.
|
||||
function detectBrowserLanguage(available: string[]): string | null {
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
|
||||
for (const tag of tags) {
|
||||
const base = tag.toLowerCase().split("-")[0];
|
||||
if (available.includes(base)) return base;
|
||||
const lower = tag.toLowerCase();
|
||||
const exact = byLower.get(lower);
|
||||
if (exact) return exact;
|
||||
const base = byLower.get(lower.split("-")[0]);
|
||||
if (base) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,45 +10,68 @@ import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
|
||||
// connState collapses them into Connecting or Disconnected upstream.
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
[ConnectionState.Connected]: "connect.status.connected",
|
||||
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
|
||||
};
|
||||
|
||||
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
|
||||
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
|
||||
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
|
||||
// device code.
|
||||
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
|
||||
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
|
||||
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
|
||||
// The tray emits it from menu items so the React UI (which owns the SSO
|
||||
// orchestration and the browser-login window) takes over.
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
// Re-enable the switch after this long in a transitioning state so the user
|
||||
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
|
||||
// popup window is the only login UI; errors surface as a native
|
||||
// Dialogs.Error. Concurrent calls are dropped via the inFlight guard.
|
||||
// loginInFlight is a module-level guard. SSO login involves multiple async
|
||||
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
|
||||
// call would race on the daemon's pending device code and on the popup
|
||||
// window's singleton, leading to confusing UX. Calls past the first are
|
||||
// dropped silently — the first invocation owns the flow until it settles.
|
||||
let loginInFlight = false;
|
||||
async function startLogin(): Promise<void> {
|
||||
if (loginInFlight) return;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end:
|
||||
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
|
||||
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
|
||||
// 3. Race WaitSSOLogin vs the user clicking Cancel.
|
||||
// 4. On success: Connection.Up.
|
||||
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
|
||||
// drops the abandoned device code (avoids an Idle blink on the tray).
|
||||
//
|
||||
// Errors that aren't user cancellations surface via errorDialog. Concurrent
|
||||
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
|
||||
// all exit paths so a stray popup doesn't outlive the flow.
|
||||
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
|
||||
// instant the flow itself is over (success, cancel, or error) — BEFORE the
|
||||
// error dialog is shown. Every guard that gates re-arming the login path
|
||||
// (the module-level loginInFlight here, and the caller's React-level
|
||||
// loginGuard via onSettled) must be released at that point, never gated on
|
||||
// the dialog.
|
||||
//
|
||||
// Why the dialog must be outside the guards: the native Windows MessageBox
|
||||
// disables its parent for its whole lifetime, and the main window's
|
||||
// WindowClosing hook hides instead of closing — the two race and the dialog
|
||||
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
|
||||
// release awaited the dialog, that guard would stay held for as long as the
|
||||
// box is open (or forever if it hangs), and every later Connect / tray
|
||||
// trigger-login would be silently dropped at the guard check until the
|
||||
// client is restarted. That was the original "can't log in again until
|
||||
// restart" bug.
|
||||
async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
if (loginInFlight) {
|
||||
// The caller's guard must still be released — it was set before this
|
||||
// call. Without this the React-level loginGuard would wedge on a
|
||||
// dropped concurrent invocation.
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
loginInFlight = true;
|
||||
|
||||
let cancelled = false;
|
||||
let offCancel: (() => void) | undefined;
|
||||
let loginError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
@@ -64,10 +87,6 @@ async function startLogin(): Promise<void> {
|
||||
if (result.needsSsoLogin) {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
if (uri) {
|
||||
// Open the in-app sign-in popup first; the dialog itself
|
||||
// fires Connection.OpenURL after it's actually on screen
|
||||
// (see WaitingForBrowserDialog) so the system browser
|
||||
// doesn't land on top of a still-hidden NetBird window.
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
@@ -94,12 +113,6 @@ async function startLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
// Cancel the in-flight WaitSSOLogin gRPC instead of a heavy
|
||||
// Down. The daemon ties the wait to this call's context
|
||||
// (server.go WaitSSOLogin), so cancelling ends the wait and
|
||||
// clears the abandoned OAuth flow — a fresh Login then starts
|
||||
// a new device code, with no Idle blink on the tray. Swallow
|
||||
// the cancellation rejection on the abandoned promise.
|
||||
waitPromise.cancel?.();
|
||||
void waitPromise.catch(() => {});
|
||||
return;
|
||||
@@ -109,17 +122,48 @@ async function startLogin(): Promise<void> {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (cancelled) return;
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
if (!cancelled) loginError = e;
|
||||
} finally {
|
||||
offCancel?.();
|
||||
// Release every guard before any UI work below — never gate re-arming
|
||||
// the login path on a dialog that can hang. loginInFlight is ours;
|
||||
// onSettled releases the caller's React-level loginGuard.
|
||||
loginInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
|
||||
if (loginError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(loginError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
|
||||
// connState collapses them into Connecting or Disconnected upstream.
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
[ConnectionState.Connected]: "connect.status.connected",
|
||||
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
|
||||
};
|
||||
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
// Re-enable the switch after this long in a transitioning state so the user
|
||||
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
export const MainConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
@@ -151,7 +195,12 @@ export const MainConnectionStatusSwitch = () => {
|
||||
if (loginGuard.current) return;
|
||||
loginGuard.current = true;
|
||||
setAction("logging-in");
|
||||
void startLogin().finally(() => {
|
||||
// Release the React-level guard via onSettled — fired the instant the
|
||||
// flow ends, before startLogin's error dialog. Gating it on the full
|
||||
// startLogin() promise would keep loginGuard wedged for the whole
|
||||
// dialog lifetime, leaving the tray's trigger-login dropped at the
|
||||
// guard check until the client is restarted.
|
||||
void startLogin(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
void refresh();
|
||||
@@ -343,7 +392,11 @@ export const MainConnectionStatusSwitch = () => {
|
||||
const ip = status?.local.ip || "";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full w-full items-center justify-center gap-4 relative -top-6",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
@@ -370,14 +423,17 @@ export const MainConnectionStatusSwitch = () => {
|
||||
<CopyToClipboard
|
||||
message={fqdn}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300",
|
||||
"min-h-[1em] transition-opacity duration-300 max-w-full",
|
||||
"relative left-[0.55rem]",
|
||||
showLocal && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
|
||||
{fqdn || " "}
|
||||
</span>
|
||||
<TruncatedText
|
||||
text={fqdn || " "}
|
||||
className={
|
||||
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
@@ -387,7 +443,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
|
||||
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
|
||||
{ip || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
|
||||
215
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
215
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { forwardRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronsUpDown, LucideProps, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { mockExitNodes, mockOr } from "@/lib/mock";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const MainExitNodeSwitcher = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
|
||||
const exitNodes = mockOr(realExitNodes, mockExitNodes);
|
||||
const active = exitNodes.find((n) => n.selected) ?? null;
|
||||
const isConnected = status?.status === "Connected";
|
||||
const hasAny = exitNodes.length > 0;
|
||||
const disabled = !isConnected || !hasAny;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = (next: string) => {
|
||||
setOpen(false);
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) void toggleExitNode(active.id, true);
|
||||
return;
|
||||
}
|
||||
if (active && active.id === next) return;
|
||||
void toggleExitNode(next, false);
|
||||
};
|
||||
|
||||
const title = active ? active.id : t("exitNodes.card.title");
|
||||
const description = !hasAny
|
||||
? t("exitNodes.empty.title")
|
||||
: active
|
||||
? t("exitNodes.card.statusActive")
|
||||
: t("exitNodes.card.statusInactive");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ExitNodeTriggerCard
|
||||
title={title}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
active={!!active}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"center"}
|
||||
side={"top"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Command.List>
|
||||
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
|
||||
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
|
||||
{hasAny && (
|
||||
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
|
||||
<ScrollArea.Viewport className={"max-h-72 px-1"}>
|
||||
{exitNodes.map((n) => (
|
||||
<ExitNodeRow
|
||||
key={n.id}
|
||||
id={n.id}
|
||||
label={n.id}
|
||||
isActive={active?.id === n.id}
|
||||
onSelect={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
title: string;
|
||||
description: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
function ExitNodeTriggerCard(
|
||||
{ title, description, disabled, active = false, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={"button"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||
active
|
||||
? "bg-green-500/25 text-green-400"
|
||||
: "bg-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<ExitNodeIcon size={14} />
|
||||
</div>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
|
||||
<TruncatedText
|
||||
text={description}
|
||||
className={
|
||||
"block text-[0.85rem] font-medium text-nb-gray-400 truncate max-w-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type NoneRowProps = {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Command.Item
|
||||
value={NONE_VALUE}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-2 pr-3",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodeRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
||||
<Command.Item
|
||||
value={id}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-2 pr-3",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
|
||||
import {isWindows} from "@/lib/platform.ts";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
|
||||
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
|
||||
|
||||
@@ -143,21 +143,24 @@ export const MainHeader = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 cursor-default wails-draggable relative",
|
||||
"flex items-center h-12 top-2.5",
|
||||
"shrink-0 cursor-default wails-draggable relative z-10",
|
||||
"flex items-center h-12 top-3",
|
||||
)}
|
||||
>
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div className={cn("grid grid-cols-3 items-center shrink-0", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-3 items-center shrink-0",
|
||||
isWindows() ? "w-[364px]" : "w-[380px]",
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
|
||||
{settingsSlot}
|
||||
</div>
|
||||
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
|
||||
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
|
||||
import { MainHeader } from "@/modules/main/MainHeader.tsx";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
|
||||
@@ -9,7 +10,6 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
|
||||
@@ -37,8 +37,11 @@ const MainBody = () => {
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div className={cn("flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<MainConnectionStatusSwitch />
|
||||
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
|
||||
<MainExitNodeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<NavSectionProvider>
|
||||
@@ -56,7 +59,11 @@ const AdvancedAppRightPanel = () => {
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
className={"m-5 ml-0"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
@@ -68,7 +75,6 @@ const AdvancedAppRightPanel = () => {
|
||||
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "exitNode" && <ExitNodes />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -28,11 +28,6 @@ export const Navigation = () => {
|
||||
label: t("nav.resources.title"),
|
||||
icon: Layers3Icon,
|
||||
},
|
||||
{
|
||||
value: "exitNode",
|
||||
label: t("nav.exitNode.title"),
|
||||
icon: ExitNodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -72,12 +67,4 @@ export const Navigation = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
export type { NavSection } from "@/contexts/NavSectionContext";
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { mockExitNodes, mockOr } from "@/lib/mock";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const ExitNodes = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
|
||||
const exitNodes = mockOr(realExitNodes, mockExitNodes);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Initial order: active-first, then by id. After that, positions are sticky
|
||||
// — toggling a row doesn't move it. Mirrors the networks-list behavior so
|
||||
// the optimistic radio flip paints in place instead of the row jumping to
|
||||
// the top.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const byId = new Map(exitNodes.map((n) => [n.id, n]));
|
||||
const kept = orderRef.current.filter((id) => byId.has(id));
|
||||
const known = new Set(kept);
|
||||
const fresh = exitNodes
|
||||
.filter((n) => !known.has(n.id))
|
||||
.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((n) => n.id);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((id) => byId.get(id)!);
|
||||
}, [exitNodes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return ordered;
|
||||
return ordered.filter((r) => r.id.toLowerCase().includes(q));
|
||||
}, [ordered, search]);
|
||||
|
||||
if (isConnected && exitNodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={WaypointsIcon}
|
||||
title={t("exitNodes.empty.title")}
|
||||
description={t("exitNodes.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
|
||||
learnMoreTopic={t("nav.exitNode.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("exitNodes.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodesListProps = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const active = data.find((n) => n.selected) ?? null;
|
||||
const value = active?.id ?? NONE_VALUE;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (next === value) return;
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) onToggle(active.id, true);
|
||||
return;
|
||||
}
|
||||
onToggle(next, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
className={"flex flex-col"}
|
||||
>
|
||||
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
|
||||
{data.map((n) => (
|
||||
<Row key={n.id} value={n.id} label={n.id} />
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
const Row = ({ value, label, first }: RowProps) => (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
|
||||
first && "mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer outline-none text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border",
|
||||
"border-nb-gray-700 bg-nb-gray-900",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={"h-2 w-2 rounded-full bg-white"}
|
||||
/>
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
@@ -39,23 +39,17 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-100",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[active.value]})
|
||||
</span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
@@ -76,11 +77,7 @@ export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const {
|
||||
networkRoutes: realNetworkRoutes,
|
||||
toggleNetwork,
|
||||
setNetworksSelected,
|
||||
} = useNetworks();
|
||||
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
@@ -164,9 +161,7 @@ export const Networks = () => {
|
||||
|
||||
const selectedInView = filtered.filter((r) => r.selected).length;
|
||||
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
|
||||
const bulkLabel = allSelected
|
||||
? t("networks.bulk.disableAll")
|
||||
: t("networks.bulk.enableAll");
|
||||
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
|
||||
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
@@ -262,7 +257,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
key={n.id}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
@@ -271,29 +266,21 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={n.id}>
|
||||
<span
|
||||
<TruncatedText
|
||||
text={n.id}
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
>
|
||||
{n.id}
|
||||
</span>
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<Subtitle network={n} />
|
||||
</div>
|
||||
<div
|
||||
className={"shrink-0 self-center"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
|
||||
<NetworkToggle
|
||||
checked={n.selected}
|
||||
onChange={() => onToggle(n.id, n.selected)}
|
||||
label={
|
||||
n.selected
|
||||
? t("networks.selected")
|
||||
: t("networks.unselected")
|
||||
}
|
||||
label={n.selected ? t("networks.selected") : t("networks.unselected")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -307,7 +294,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
|
||||
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
@@ -327,9 +314,12 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
return (
|
||||
<div>
|
||||
<CopyToClipboard message={network.range}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{network.range}
|
||||
</span>
|
||||
<TruncatedText
|
||||
text={network.range}
|
||||
className={
|
||||
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
@@ -339,90 +329,63 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
};
|
||||
|
||||
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
|
||||
const first = ips[0];
|
||||
const extra = ips.length - 1;
|
||||
|
||||
const span = (
|
||||
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CopyToClipboard message={domain}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{domain}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{first && (
|
||||
<div className={"flex items-center gap-1.5 min-w-0"}>
|
||||
<CopyToClipboard message={first}>
|
||||
<span className={"text-xs font-mono text-nb-gray-500 truncate"}>
|
||||
{first}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div>
|
||||
<CopyToClipboard message={domain}>
|
||||
{ips.length > 0 ? (
|
||||
<Tooltip
|
||||
content={<ResolvedIpsTooltip ips={ips} />}
|
||||
delayDuration={300}
|
||||
closeDelay={300}
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
alignOffset={-8}
|
||||
interactive
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-w-[18rem] max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
{span}
|
||||
</Tooltip>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
|
||||
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const extra = ips.length - 1;
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
|
||||
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-pointer outline-none",
|
||||
)}
|
||||
>
|
||||
{t("networks.ips.more", { count: extra })}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side={"bottom"}
|
||||
align={"start"}
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-500"
|
||||
}
|
||||
>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
className={"px-1 py-0.5"}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<>
|
||||
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-100 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -450,11 +413,7 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
mixed
|
||||
? "translate-x-2.5"
|
||||
: checked
|
||||
? "translate-x-[1.125rem]"
|
||||
: "translate-x-0.5",
|
||||
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
@@ -16,7 +8,8 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
CableIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
ClockIcon,
|
||||
GaugeIcon,
|
||||
HandshakeIcon,
|
||||
@@ -25,15 +18,15 @@ import {
|
||||
LucideProps,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
NetworkIcon,
|
||||
Radio,
|
||||
RefreshCwIcon,
|
||||
ZapIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
@@ -222,7 +215,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
|
||||
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const ConnectionIcon = peer.relayed ? NetworkIcon : ZapIcon;
|
||||
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
|
||||
|
||||
return (
|
||||
@@ -242,11 +234,24 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
)}
|
||||
</Row>
|
||||
{isConnected && (
|
||||
<Row icon={CableIcon} label={t("peers.details.connection")}>
|
||||
<span className={"inline-flex items-center gap-1.5 whitespace-nowrap"}>
|
||||
<ConnectionIcon size={13} />
|
||||
{connectionLabel}
|
||||
</span>
|
||||
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
|
||||
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
|
||||
</Row>
|
||||
)}
|
||||
{peer.relayed && (
|
||||
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.latencyMs > 0 && (
|
||||
@@ -282,6 +287,11 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
|
||||
{statusSince}
|
||||
</Row>
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<IceRow
|
||||
icon={MonitorIcon}
|
||||
baseLabel={t("peers.details.localIce")}
|
||||
@@ -294,27 +304,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
type={peer.remoteIceCandidateType}
|
||||
endpoint={peer.remoteIceCandidateEndpoint}
|
||||
/>
|
||||
{peer.relayed && (
|
||||
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
|
||||
{peer.pubKey ? (
|
||||
<CopyToClipboard
|
||||
@@ -370,67 +359,35 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// "{N} Resources" trigger that opens a hover popover listing each routed
|
||||
// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs
|
||||
// popover in the Resources tab (Networks.tsx ResolvedIpsPopover).
|
||||
// Row value: first network inline, plus a "+N more" hover pill opening a
|
||||
// popover with the full list. Mirrors the resolved-IPs pattern in
|
||||
// Networks.tsx so the Resources tab and the peer detail row look consistent.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => {
|
||||
const first = networks[0];
|
||||
const extra = networks.length - 1;
|
||||
return (
|
||||
<div className={"flex items-center gap-1.5 min-w-0 justify-end"}>
|
||||
<CopyToClipboard message={first}>
|
||||
<TruncatedRowValue value={first} mono />
|
||||
</CopyToClipboard>
|
||||
{extra > 0 && <ResourcesMorePopover networks={networks} extra={extra} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Single "View {n}" badge with a chevron that opens a click popover listing
|
||||
// each routed resource on its own line with a click-to-copy entry. Avoids
|
||||
// the repetitive "first item + N more" pattern given the row already has a
|
||||
// "Resources" label and Layers icon.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => (
|
||||
<ResourcesPopover networks={networks} />
|
||||
);
|
||||
|
||||
const ResourcesMorePopover = ({
|
||||
networks,
|
||||
extra,
|
||||
}: {
|
||||
networks: string[];
|
||||
extra: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
// Close with a small delay so the mouse can cross the sideOffset gap
|
||||
// between trigger and content without the popover snapping shut.
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 300);
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
className={cn(
|
||||
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
|
||||
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none",
|
||||
"shrink-0 inline-flex items-center gap-1 rounded",
|
||||
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
|
||||
"border border-nb-gray-900",
|
||||
"px-2 py-1 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
)}
|
||||
>
|
||||
{t("peers.details.more", { count: extra })}
|
||||
{networks.length}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -438,13 +395,11 @@ const ResourcesMorePopover = ({
|
||||
side={"bottom"}
|
||||
align={"end"}
|
||||
sideOffset={6}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"z-50 max-w-[18rem] max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 shadow-lg outline-none",
|
||||
"p-2 pr-4 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<ul className={"flex flex-col"}>
|
||||
@@ -453,7 +408,7 @@ const ResourcesMorePopover = ({
|
||||
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{n}
|
||||
@@ -468,37 +423,15 @@ const ResourcesMorePopover = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Truncates the value to one line with the row's available width; on hover,
|
||||
// shows the full string in a tooltip — but only when actually clipped. Same
|
||||
// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown.
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [value]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block truncate align-middle min-w-0 max-w-[260px]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={value} delayDuration={600}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
|
||||
<TruncatedText
|
||||
text={value}
|
||||
className={cn(
|
||||
"inline-block truncate align-middle min-w-0 max-w-[260px]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-100",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable cursor-default",
|
||||
@@ -47,15 +47,9 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[active.value]})
|
||||
</span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
@@ -68,21 +62,10 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[f.value]})
|
||||
</span>
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"w-4 shrink-0 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
)}
|
||||
<span className={"w-4 shrink-0 flex items-center justify-center"}>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
|
||||
@@ -12,6 +12,7 @@ import { latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { mockOr, mockPeers } from "@/lib/mock";
|
||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||
|
||||
@@ -67,27 +68,55 @@ export const Peers = () => {
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// Initial order: online-first, then alphabetically by fqdn / ip. After
|
||||
// that, positions are sticky — a peer flipping Connected→Connecting→Idle
|
||||
// no longer jumps groups. Newly discovered peers append at the end
|
||||
// (sorted online-first / by-name among themselves). Mirrors the
|
||||
// networks-list and exit-nodes-list orderRef pattern.
|
||||
// Initial order: online-first, then alphabetically by fqdn / ip. Once
|
||||
// peers have settled, positions become sticky — a peer flipping
|
||||
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
|
||||
// peers append at the end (sorted online-first / by-name among
|
||||
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
|
||||
// pattern.
|
||||
//
|
||||
// Stay in live-sort mode until every peer has reached a stable state
|
||||
// (Connected or Idle). The daemon emits all peers as "Connecting" right
|
||||
// after Up, which collapses the online-first sort into pure
|
||||
// alphabetical — committing then would lock that incorrect order and
|
||||
// the list would stay alphabetical even after every peer becomes
|
||||
// Connected. Once nothing is Connecting we commit and go sticky.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const stickyRef = useRef(false);
|
||||
const ordered = useMemo(() => {
|
||||
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
|
||||
const kept = orderRef.current.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = peers
|
||||
.filter((p) => !known.has(p.pubKey))
|
||||
.sort((a, b) => {
|
||||
const sortOnlineFirst = (list: PeerStatus[]) =>
|
||||
[...list].sort((a, b) => {
|
||||
const aOnline = isOnline(a.connStatus);
|
||||
const bOnline = isOnline(b.connStatus);
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||
const aName = (a.fqdn || a.ip).toLowerCase();
|
||||
const bName = (b.fqdn || b.ip).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
})
|
||||
.map((p) => p.pubKey);
|
||||
});
|
||||
|
||||
// Reset on empty (Disconnect → reconnect) so the next session
|
||||
// re-sorts from scratch instead of replaying the stale orderRef.
|
||||
if (peers.length === 0) {
|
||||
orderRef.current = [];
|
||||
stickyRef.current = false;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!stickyRef.current) {
|
||||
const sorted = sortOnlineFirst(peers);
|
||||
if (peers.every((p) => p.connStatus !== "Connecting")) {
|
||||
orderRef.current = sorted.map((p) => p.pubKey);
|
||||
stickyRef.current = true;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
|
||||
const kept = orderRef.current.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
|
||||
(p) => p.pubKey,
|
||||
);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((k) => byKey.get(k)!);
|
||||
@@ -171,15 +200,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
content={t(peerStatusLabelKey(peer.connStatus))}
|
||||
side={"left"}
|
||||
>
|
||||
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
@@ -190,7 +216,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<TruncatedName name={peer.fqdn} />
|
||||
<TruncatedText
|
||||
text={peer.fqdn}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
@@ -224,35 +255,3 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a
|
||||
// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout,
|
||||
// and wrap in a Tooltip only when the text actually overflows. Avoids the
|
||||
// "tooltip on hover even though everything fits" annoyance.
|
||||
const TruncatedName = ({ name }: { name: string }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [name]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={name} delayDuration={600}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,79 +1,216 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
|
||||
import {
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
ManagementMode,
|
||||
checkManagementUrlReachable,
|
||||
isValidManagementUrl,
|
||||
normalizeManagementUrl,
|
||||
} from "@/hooks/useManagementUrl";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (name: string) => void;
|
||||
// onCreate receives the sanitized profile name and the management URL the
|
||||
// user picked (the cloud default for Cloud mode, the normalized self-
|
||||
// hosted URL otherwise).
|
||||
onCreate: (name: string, managementUrl: string) => void;
|
||||
};
|
||||
|
||||
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
|
||||
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
|
||||
// `_` and `-` survive on the Go side. We additionally lowercase and convert
|
||||
// spaces to `-` so what the user sees in the input is exactly what the
|
||||
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
|
||||
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
|
||||
// ghost row and breaks subsequent delete.
|
||||
const sanitizeProfileInput = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9_-]/g, "");
|
||||
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
// unreachable: soft warning. A second submit with the same URL proceeds
|
||||
// anyway (matches the onboarding management step's behaviour for self-
|
||||
// hosted servers behind internal DNS / VPN).
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("");
|
||||
setError(null);
|
||||
setNameError(null);
|
||||
setMode(ManagementMode.Cloud);
|
||||
setUrl("");
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
setChecking(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
// Reset the URL warnings whenever the user edits the URL or flips mode —
|
||||
// otherwise a stale warning lingers next to a just-corrected value.
|
||||
useEffect(() => {
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError(t("profile.dialog.required"));
|
||||
inputRef.current?.focus();
|
||||
if (checking) return;
|
||||
|
||||
const sanitized = sanitizeProfileInput(name);
|
||||
if (sanitized.length === 0) {
|
||||
setNameError(t("profile.dialog.required"));
|
||||
nameRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onCreate(trimmed);
|
||||
|
||||
if (mode === ManagementMode.Cloud) {
|
||||
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || !isValidManagementUrl(trimmed)) {
|
||||
setUrlError(t("settings.general.management.urlError"));
|
||||
urlRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = normalizeManagementUrl(trimmed);
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
// First failed check: soft warning + bail. A second submit with the
|
||||
// same URL skips re-checking (unreachable still true) so the user can
|
||||
// proceed if they're sure.
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(sanitized, target);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setName(value);
|
||||
if (error) setError(null);
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(sanitizeProfileInput(value));
|
||||
if (nameError) setNameError(null);
|
||||
};
|
||||
|
||||
// Live syntactic feedback: flag a non-empty, malformed URL as the user
|
||||
// types instead of waiting for submit. Empty is not an error yet (handled
|
||||
// on submit); the unreachable soft-warning only applies once syntax is OK.
|
||||
const trimmedUrl = url.trim();
|
||||
const showUrlSyntaxError =
|
||||
mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl);
|
||||
const urlInputError = showUrlSyntaxError
|
||||
? t("settings.general.management.urlError")
|
||||
: (urlError ?? undefined);
|
||||
// Soft, non-blocking caveat (orange) — only when the URL is otherwise OK.
|
||||
const urlInputWarning =
|
||||
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<Dialog.Content
|
||||
maxWidthClass="max-w-md"
|
||||
showClose={false}
|
||||
className="py-7"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-8">
|
||||
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
|
||||
<Dialog.Description className="mt-1">
|
||||
{t("profile.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 px-7">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={"pl-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
{t("profile.dialog.nameLabel")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("profile.dialog.description")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
ref={nameRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
error={nameError ?? undefined}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pt-3">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={"pl-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
{t("settings.general.management.label")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("profile.dialog.managementHelp")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<Input
|
||||
ref={urlRef}
|
||||
autoFocus
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={urlInputError}
|
||||
warning={urlInputWarning}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer separator={false} className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size={"md"}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
{t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
size={"xs2"}
|
||||
disabled={checking}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary"}
|
||||
size={"xs2"}
|
||||
loading={checking}
|
||||
>
|
||||
{t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
@@ -11,6 +11,10 @@ import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useConfirm } from "@/contexts/DialogContext";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { SetConfigParams } from "@bindings/services/models.js";
|
||||
import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
@@ -23,20 +27,50 @@ export function ProfilesTab() {
|
||||
profiles,
|
||||
activeProfile,
|
||||
loaded,
|
||||
username,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
} = useProfile();
|
||||
|
||||
const confirm = useConfirm();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sorted = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
// The display order is established once — the active profile first, then
|
||||
// the rest alphabetically — and then held stable for the lifetime of the
|
||||
// window. Switching profiles must only flip the "active" badge, never
|
||||
// reorder the rows (otherwise the row the user just clicked jumps to the
|
||||
// top under their cursor). New profiles append at the end; removed ones
|
||||
// drop out. `orderRef` is the source of truth for row order; the active
|
||||
// badge is derived live from `activeProfile`.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const present = new Set(profiles.map((p) => p.name));
|
||||
if (orderRef.current.length === 0) {
|
||||
// First population: active-first, then alphabetical.
|
||||
orderRef.current = [...profiles]
|
||||
.sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((p) => p.name);
|
||||
} else {
|
||||
// Preserve the established order; drop removed, append added.
|
||||
const kept = orderRef.current.filter((n) => present.has(n));
|
||||
const added = profiles
|
||||
.map((p) => p.name)
|
||||
.filter((n) => !orderRef.current.includes(n))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
orderRef.current = [...kept, ...added];
|
||||
}
|
||||
const byName = new Map(profiles.map((p) => [p.name, p]));
|
||||
return orderRef.current
|
||||
.map((n) => byName.get(n))
|
||||
.filter((p): p is Profile => p !== undefined);
|
||||
}, [profiles, activeProfile]);
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
@@ -53,40 +87,53 @@ export function ProfilesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("profile.deregister.confirm");
|
||||
const result = await warningDialog({
|
||||
Title: i18next.t("profile.deregister.title"),
|
||||
Message: i18next.t("profile.deregister.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
const handleSwitch = async (name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("profile.switch.title", { name }),
|
||||
description: t("profile.switch.message", { name }),
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (result !== confirmLabel) return;
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("profile.deregister.title", { name }),
|
||||
description: t("profile.deregister.message", { name }),
|
||||
confirmLabel: t("profile.deregister.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (name === DEFAULT_PROFILE) return;
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("common.delete");
|
||||
const result = await warningDialog({
|
||||
Title: i18next.t("profile.delete.title"),
|
||||
Message: i18next.t("profile.delete.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
const ok = await confirm({
|
||||
title: t("profile.delete.title", { name }),
|
||||
description: t("profile.delete.message", { name }),
|
||||
confirmLabel: t("common.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (result !== confirmLabel) return;
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string) => {
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
try {
|
||||
await addProfile(name);
|
||||
// Only persist a management URL for self-hosted; a fresh profile
|
||||
// already defaults to NetBird Cloud, so writing the cloud URL
|
||||
// would be a no-op. Do it before switching so any reconnect the
|
||||
// switch triggers already targets the right deployment. SetConfig
|
||||
// is keyed by profile name, so it writes the new profile even
|
||||
// though it isn't active yet (adminUrl left empty — the daemon
|
||||
// keeps its loaded value).
|
||||
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: name, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(name);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
@@ -97,7 +144,7 @@ export function ProfilesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
@@ -108,11 +155,12 @@ export function ProfilesTab() {
|
||||
>
|
||||
<table className={"w-full text-sm"}>
|
||||
<tbody>
|
||||
{sorted.map((profile) => (
|
||||
{ordered.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSwitch={() => handleSwitch(profile.name)}
|
||||
onDeregister={() => handleDeregister(profile.name)}
|
||||
onDelete={() => handleDelete(profile.name)}
|
||||
/>
|
||||
@@ -120,7 +168,7 @@ export function ProfilesTab() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{loaded && sorted.length === 0 && (
|
||||
{loaded && ordered.length === 0 && (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center py-10 text-center"
|
||||
@@ -146,18 +194,19 @@ export function ProfilesTab() {
|
||||
</SectionGroup>
|
||||
|
||||
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
onSwitch: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
|
||||
const showEmail = !!profile.email;
|
||||
@@ -192,8 +241,11 @@ const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowPro
|
||||
</td>
|
||||
<td className={"px-4 py-2.5 text-right align-middle"}>
|
||||
<RowActions
|
||||
canSwitch={!isActive}
|
||||
canDeregister={!!profile.email}
|
||||
canDelete={profile.name !== DEFAULT_PROFILE}
|
||||
isDefault={profile.name === DEFAULT_PROFILE}
|
||||
isActive={isActive}
|
||||
onSwitch={onSwitch}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@@ -222,14 +274,31 @@ const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
};
|
||||
|
||||
type RowActionsProps = {
|
||||
canSwitch: boolean;
|
||||
canDeregister: boolean;
|
||||
canDelete: boolean;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
onSwitch: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => {
|
||||
const RowActions = ({
|
||||
canSwitch,
|
||||
canDeregister,
|
||||
isDefault,
|
||||
isActive,
|
||||
onSwitch,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteDisabled = isDefault || isActive;
|
||||
const deleteLabel = isDefault
|
||||
? t("profile.delete.disabledDefault")
|
||||
: isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: t("profile.selector.delete");
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
@@ -239,11 +308,17 @@ const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowAct
|
||||
hidden={!canDeregister}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.delete")}
|
||||
label={deleteLabel}
|
||||
icon={Trash2}
|
||||
onClick={onDelete}
|
||||
variant={"danger"}
|
||||
hidden={!canDelete}
|
||||
disabled={deleteDisabled}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.switchTo")}
|
||||
icon={LogIn}
|
||||
onClick={onSwitch}
|
||||
hidden={!canSwitch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -257,6 +332,8 @@ type ActionIconButtonProps = {
|
||||
/** When true the button still occupies space (preserves row layout)
|
||||
* but is invisible and non-interactive. */
|
||||
hidden?: boolean;
|
||||
/** When true the button is visible but non-interactive (greyed out). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ActionIconButton = ({
|
||||
@@ -265,13 +342,15 @@ const ActionIconButton = ({
|
||||
onClick,
|
||||
variant = "default",
|
||||
hidden = false,
|
||||
disabled = false,
|
||||
}: ActionIconButtonProps) => {
|
||||
const button = (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onClick}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-label={label}
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
className={cn(
|
||||
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
|
||||
@@ -280,6 +359,7 @@ const ActionIconButton = ({
|
||||
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
|
||||
hidden && "opacity-0 pointer-events-none",
|
||||
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
@@ -287,7 +367,12 @@ const ActionIconButton = ({
|
||||
);
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip content={label} side={"top"}>
|
||||
<Tooltip
|
||||
content={
|
||||
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
|
||||
}
|
||||
side={"top"}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SettingsAbout() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
|
||||
@@ -18,7 +18,8 @@ const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/;
|
||||
const INTERFACE_NAME_ERROR_KEY = IS_MAC
|
||||
? "settings.advanced.interfaceName.errorMac"
|
||||
: "settings.advanced.interfaceName.error";
|
||||
const PORT_MIN = 1;
|
||||
// Port 0 means "let the daemon pick a random free port" (see the hint text).
|
||||
const PORT_MIN = 0;
|
||||
const PORT_MAX = 65535;
|
||||
// Mirrors client/iface/iface.go MinMTU / MaxMTU. 576 is the IPv4 "every host
|
||||
// must accept" datagram size from RFC 791 — safe floor when IPv6 is off; for
|
||||
@@ -93,20 +94,23 @@ export function SettingsAdvanced() {
|
||||
}
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<Input
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
min={PORT_MIN}
|
||||
max={PORT_MAX}
|
||||
value={values.wireguardPort}
|
||||
error={errors.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
min={PORT_MIN}
|
||||
max={PORT_MAX}
|
||||
value={values.wireguardPort}
|
||||
error={errors.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<HelpText className={"mt-1.5"}>{t("settings.advanced.port.help")}</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
label={t("settings.advanced.mtu.label")}
|
||||
type={"number"}
|
||||
|
||||
@@ -15,7 +15,17 @@ export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { autostart, setAutostartEnabled } = useAutostartSetting();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
setUrl,
|
||||
displayUrl,
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
} = useManagementUrl();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMode = useRef(mode);
|
||||
@@ -79,11 +89,17 @@ export function SettingsGeneral() {
|
||||
? t("settings.general.management.urlError")
|
||||
: undefined
|
||||
}
|
||||
warning={
|
||||
unreachable
|
||||
? t("settings.general.management.urlUnreachable")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!canSave}
|
||||
loading={checking}
|
||||
onClick={() => save()}
|
||||
>
|
||||
{t("common.save")}
|
||||
|
||||
@@ -46,8 +46,8 @@ export function SettingsNetwork() {
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableIpv6}
|
||||
onChange={(v) => setField("disableIpv6", !v)}
|
||||
label={"Enable IPv6"}
|
||||
helpText={"Use IPv6 addressing for the NetBird overlay network."}
|
||||
label={t("settings.network.ipv6.label")}
|
||||
helpText={t("settings.network.ipv6.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
</>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const SectionGroup = ({
|
||||
// scrollable content above doesn't end up hidden behind the bar.
|
||||
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={"h-[4.1rem] shrink-0"} aria-hidden />
|
||||
<div className={"h-[4rem] shrink-0"} aria-hidden />
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
|
||||
193
client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx
Normal file
193
client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Preferences,
|
||||
Profiles as ProfilesSvc,
|
||||
Settings as SettingsSvc,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { SetConfigParams } from "@bindings/services/models.js";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog } from "@/lib/dialogs";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
|
||||
import { WelcomeStepTray } from "./WelcomeStepTray";
|
||||
import { WelcomeStepManagement } from "./WelcomeStepManagement";
|
||||
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
// WelcomeStep is the orchestrator's state machine. The transitions:
|
||||
// tray → management (if eligible) → finish
|
||||
// tray → finish (otherwise)
|
||||
// Login itself is no longer part of onboarding — once the welcome window
|
||||
// closes the user lands in the main window and clicks Connect there.
|
||||
type WelcomeStep = "tray" | "management";
|
||||
|
||||
// shouldShowManagementStep asks the user about Cloud vs self-hosted only
|
||||
// on a pristine setup — default profile, no email recorded (no successful
|
||||
// login yet), and the management URL is either unset or already the cloud
|
||||
// default. Any other state means the user (or a previous run) already
|
||||
// made a deliberate choice and we shouldn't second-guess it.
|
||||
function shouldShowManagementStep(
|
||||
activeProfile: string,
|
||||
email: string,
|
||||
managementUrl: string,
|
||||
): boolean {
|
||||
if (activeProfile !== "default") return false;
|
||||
if (email.trim() !== "") return false;
|
||||
return isCloudManagementUrl(managementUrl);
|
||||
}
|
||||
|
||||
// initial flow snapshot resolved at mount. Held in component state so the
|
||||
// step-2 management input can hydrate from initialUrl, and so the
|
||||
// "should we even show step 2" check is computed once (the user can't
|
||||
// change profile / URL from inside the welcome window).
|
||||
type InitialState = {
|
||||
profileName: string;
|
||||
username: string;
|
||||
managementUrl: string;
|
||||
needsManagementStep: boolean;
|
||||
};
|
||||
|
||||
export default function WelcomeDialog() {
|
||||
const [step, setStep] = useState<WelcomeStep>("tray");
|
||||
const [initial, setInitial] = useState<InitialState | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
// ready=false until the daemon probe resolves — keeps the window
|
||||
// Hidden so neither the empty padding-only frame (Linux/GNOME paints
|
||||
// through) nor a placeholder div leaks onto screen.
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
|
||||
|
||||
// Probe daemon state on mount: who's the active profile, do they
|
||||
// have an email recorded, and what management URL is configured?
|
||||
// Errors fall through to "skip the management step" so a daemon
|
||||
// hiccup never blocks onboarding entirely.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// Resolve username + active profile first so GetConfig + List
|
||||
// can target the actual profile (passing empty strings would
|
||||
// work today since the daemon falls back to the default
|
||||
// profile, but being explicit shields us from future
|
||||
// changes to that fallback).
|
||||
const [username, active] = await Promise.all([
|
||||
ProfilesSvc.Username(),
|
||||
ProfilesSvc.GetActive(),
|
||||
]);
|
||||
const profileName = active.profileName || "default";
|
||||
const [config, list] = await Promise.all([
|
||||
SettingsSvc.GetConfig({ profileName, username }),
|
||||
ProfilesSvc.List(username),
|
||||
]);
|
||||
const profile = list.find((p) => p.name === profileName);
|
||||
const email = profile?.email ?? "";
|
||||
if (cancelled) return;
|
||||
setInitial({
|
||||
profileName,
|
||||
username,
|
||||
managementUrl: config.managementUrl,
|
||||
needsManagementStep: shouldShowManagementStep(
|
||||
profileName,
|
||||
email,
|
||||
config.managementUrl,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("welcome: initial probe failed", e);
|
||||
if (cancelled) return;
|
||||
// Conservative fallback: skip the management step rather
|
||||
// than block onboarding behind a daemon hiccup.
|
||||
setInitial({
|
||||
profileName: "default",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
needsManagementStep: false,
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// finish persists the onboarding flag, opens the main window so the
|
||||
// user has somewhere to land, and closes the welcome window. Called
|
||||
// at the end of every successful flow (tray-only and tray→management
|
||||
// alike). The Connect button in the main window picks up from here.
|
||||
const finish = useCallback(async () => {
|
||||
if (closing) return;
|
||||
setClosing(true);
|
||||
try {
|
||||
await Preferences.SetOnboardingCompleted(true);
|
||||
} catch (e) {
|
||||
console.error("persist onboarding flag:", e);
|
||||
}
|
||||
try {
|
||||
await WindowManager.OpenMain();
|
||||
} catch (e) {
|
||||
console.error("open main window:", e);
|
||||
}
|
||||
try {
|
||||
await WindowManager.CloseWelcome();
|
||||
} catch (e) {
|
||||
console.error("close welcome window:", e);
|
||||
}
|
||||
}, [closing]);
|
||||
|
||||
const handleTrayContinue = useCallback(async () => {
|
||||
if (initial?.needsManagementStep) {
|
||||
setStep("management");
|
||||
} else {
|
||||
await finish();
|
||||
}
|
||||
}, [initial, finish]);
|
||||
|
||||
const handleManagementContinue = useCallback(
|
||||
async (url: string) => {
|
||||
if (!initial) return;
|
||||
try {
|
||||
// SetConfig is a partial update — pointer fields left
|
||||
// undefined are preserved (services/settings.go). We only
|
||||
// touch managementUrl; adminUrl stays empty here because
|
||||
// the daemon already has its own value loaded.
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({
|
||||
profileName: initial.profileName,
|
||||
username: initial.username,
|
||||
managementUrl: url,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
|
||||
await finish();
|
||||
},
|
||||
[initial, finish],
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!initial) {
|
||||
return null;
|
||||
}
|
||||
switch (step) {
|
||||
case "tray":
|
||||
return <WelcomeStepTray onContinue={handleTrayContinue} />;
|
||||
case "management":
|
||||
return (
|
||||
<WelcomeStepManagement
|
||||
initialUrl={initial.managementUrl}
|
||||
onContinue={handleManagementContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [initial, step, handleTrayContinue, handleManagementContinue]);
|
||||
|
||||
return <ConfirmDialog ref={contentRef}>{content}</ConfirmDialog>;
|
||||
}
|
||||
141
client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx
Normal file
141
client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
|
||||
import {
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
ManagementMode,
|
||||
checkManagementUrlReachable,
|
||||
isCloudManagementUrl,
|
||||
isValidManagementUrl,
|
||||
normalizeManagementUrl,
|
||||
} from "@/hooks/useManagementUrl";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { isMacOS } from "@/lib/platform.ts";
|
||||
|
||||
type WelcomeStepManagementProps = {
|
||||
// initialUrl is the management URL the daemon is already configured
|
||||
// with (empty / cloud-default both render as Cloud selected).
|
||||
initialUrl: string;
|
||||
// onContinue is invoked with the URL the user wants to persist. The
|
||||
// parent owns the actual Settings.SetConfig call so the dialog stays
|
||||
// free of context dependencies.
|
||||
onContinue: (url: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
|
||||
const { t } = useTranslation();
|
||||
const startsCloud = isCloudManagementUrl(initialUrl);
|
||||
const [mode, setMode] = useState<ManagementMode>(
|
||||
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
|
||||
);
|
||||
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
// unreachable: soft warning. Continue stays enabled — user can confirm
|
||||
// they typed it right and proceed (matches self-hosted-behind-internal-
|
||||
// DNS / VPN scenarios where the in-app fetch would false-negative).
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
|
||||
// Continue is no longer disabled for an empty / invalid self-hosted
|
||||
// URL; a Continue click in that state focuses the input and renders
|
||||
// an inline error so the user actively notices what's missing.
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Reset inline error/warning whenever the user edits the URL or flips
|
||||
// mode — otherwise the warning lingers next to a just-corrected value.
|
||||
useEffect(() => {
|
||||
setSyntaxError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (checking) return;
|
||||
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
|
||||
// Empty or syntactically invalid URL — Continue stays enabled
|
||||
// so the click registers; surface the error inline and focus
|
||||
// the input so the user has somewhere to fix it.
|
||||
setSyntaxError(t("welcome.management.urlInvalid"));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
mode === ManagementMode.Cloud
|
||||
? CLOUD_MANAGEMENT_URL
|
||||
: normalizeManagementUrl(trimmedUrl);
|
||||
if (mode === ManagementMode.SelfHosted) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
// First failed check: show soft warning + bail. A second click
|
||||
// with the same URL skips the check (unreachable still true)
|
||||
// so the user can proceed if they're sure.
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onContinue(target);
|
||||
} catch (e) {
|
||||
// Parent surfaces save errors via errorDialog; keep a console
|
||||
// breadcrumb but don't double-render.
|
||||
console.error("save management url:", e);
|
||||
}
|
||||
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
|
||||
|
||||
// Syntax problems are hard errors (red); an unreachable-but-valid URL is
|
||||
// a soft, non-blocking caveat (orange).
|
||||
const inputError = syntaxError ?? undefined;
|
||||
const inputWarning = useMemo(
|
||||
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
|
||||
[syntaxError, unreachable, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
|
||||
<DialogHeading align={"left"}>{t("welcome.management.title")}</DialogHeading>
|
||||
<DialogDescription align={"left"}>
|
||||
{t("welcome.management.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable w-full"}>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
|
||||
</div>
|
||||
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"wails-no-draggable w-full text-left"}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("welcome.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={inputError}
|
||||
warning={inputWarning}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={handleContinue}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? t("welcome.management.checking") : t("welcome.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx
Normal file
61
client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { isMacOS, isWindows } from "@/lib/platform";
|
||||
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
|
||||
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
|
||||
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
|
||||
|
||||
// trayScreenshotForOS picks the marketing screenshot that shows the
|
||||
// NetBird tray icon in its native menu/task bar — so the onboarding pitch
|
||||
// matches the chrome the user will actually be hunting for. Evaluated
|
||||
// inside the component so initPlatform() has finished by the time
|
||||
// isMacOS/isWindows run (the static imports above only load the bytes,
|
||||
// no platform check).
|
||||
function trayScreenshotForOS(): string {
|
||||
if (isMacOS()) return trayScreenshotDarwin;
|
||||
if (isWindows()) return trayScreenshotWindows;
|
||||
return trayScreenshotLinux;
|
||||
}
|
||||
|
||||
type WelcomeStepTrayProps = {
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"px-1.5"}>
|
||||
<img
|
||||
src={trayScreenshot}
|
||||
alt={""}
|
||||
className={"w-full h-auto select-none pointer-events-none rounded-2xl"}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col w-full gap-1"}>
|
||||
<DialogHeading align={"left"}>{t("welcome.title")}</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
tabIndex={0}
|
||||
className={"w-full"}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{t("welcome.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English", "englishName": "English"},
|
||||
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
|
||||
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
"common.saveChanges": "Änderungen speichern",
|
||||
"common.saving": "Speichert…",
|
||||
"common.close": "Schließen",
|
||||
"common.copy": "Kopieren",
|
||||
"common.togglePasswordVisibility": "Passwortsichtbarkeit umschalten",
|
||||
"common.increase": "Erhöhen",
|
||||
"common.decrease": "Verringern",
|
||||
"common.delete": "Löschen",
|
||||
"common.create": "Erstellen",
|
||||
"common.add": "Hinzufügen",
|
||||
@@ -95,6 +99,10 @@
|
||||
|
||||
"header.openSettings": "Einstellungen öffnen",
|
||||
"header.togglePanel": "Seitenleiste umschalten",
|
||||
"header.menu.settings": "Einstellungen …",
|
||||
"header.menu.defaultView": "Standardansicht",
|
||||
"header.menu.advancedView": "Erweiterte Ansicht",
|
||||
"header.menu.updateAvailable": "Update verfügbar",
|
||||
|
||||
"profile.selector.loading": "Lädt…",
|
||||
"profile.selector.noProfile": "Kein Profil",
|
||||
@@ -105,16 +113,33 @@
|
||||
"profile.selector.moreOptions": "Weitere Optionen",
|
||||
"profile.selector.deregister": "Abmelden",
|
||||
"profile.selector.delete": "Profil löschen",
|
||||
"profile.selector.switchTo": "Zu diesem Profil wechseln",
|
||||
"profile.dropdown.activeProfile": "Aktives Profil",
|
||||
"profile.dropdown.switchProfile": "Profil wechseln",
|
||||
"profile.dropdown.noEmail": "Andere",
|
||||
"profile.dropdown.addProfile": "Profil hinzufügen",
|
||||
"profile.dropdown.manageProfiles": "Profile verwalten",
|
||||
"profile.dropdown.settings": "Einstellungen",
|
||||
|
||||
"profile.dialog.title": "Neues Profil",
|
||||
"profile.dialog.description": "Mit Profilen können Sie mehrere NetBird-Verbindungen nebeneinander verwalten. Geben Sie Ihrem Profil einen aussagekräftigen Namen.",
|
||||
"profile.dialog.nameLabel": "Profilname",
|
||||
"profile.dialog.description": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest.",
|
||||
"profile.dialog.placeholder": "z. B. Arbeit",
|
||||
"profile.dialog.required": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat",
|
||||
"profile.dialog.submit": "Profil hinzufügen",
|
||||
"profile.dialog.managementHelp": "NetBird Cloud oder Ihr eigener Server.",
|
||||
"profile.dialog.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
|
||||
"profile.deregister.title": "Profil abmelden",
|
||||
"profile.deregister.message": "Sind Sie sicher, dass Sie \"{name}\" abmelden möchten? Sie müssen sich erneut anmelden, um es zu nutzen.",
|
||||
"profile.switch.title": "Zu Profil \"{name}\" wechseln?",
|
||||
"profile.switch.message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt.",
|
||||
"profile.switch.confirm": "Bestätigen",
|
||||
"profile.deregister.title": "Profil \"{name}\" abmelden?",
|
||||
"profile.deregister.message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen.",
|
||||
"profile.deregister.confirm": "Abmelden",
|
||||
"profile.delete.title": "Profil löschen",
|
||||
"profile.delete.message": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"profile.delete.title": "Profil \"{name}\" löschen?",
|
||||
"profile.delete.message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"profile.delete.disabledActive": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen.",
|
||||
"profile.delete.disabledDefault": "Das Standardprofil kann nicht gelöscht werden.",
|
||||
"profile.error.switchTitle": "Profilwechsel fehlgeschlagen",
|
||||
"profile.error.deregisterTitle": "Abmeldung fehlgeschlagen",
|
||||
"profile.error.deleteTitle": "Löschen des Profils fehlgeschlagen",
|
||||
@@ -129,11 +154,19 @@
|
||||
"settings.tabs.network": "Netzwerk",
|
||||
"settings.tabs.security": "Sicherheit",
|
||||
"settings.tabs.ssh": "SSH",
|
||||
"settings.tabs.profiles": "Profile",
|
||||
"settings.tabs.advanced": "Erweitert",
|
||||
"settings.tabs.troubleshooting": "Fehlerbehebung",
|
||||
"settings.tabs.about": "Über",
|
||||
"settings.tabs.updateAvailable": "Update verfügbar",
|
||||
|
||||
"settings.profiles.section.profiles": "Profile",
|
||||
"settings.profiles.intro": "Halten Sie separate NetBird-Identitäten nebeneinander, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie.",
|
||||
"settings.profiles.addProfile": "Profil hinzufügen",
|
||||
"settings.profiles.active": "Aktiv",
|
||||
"settings.profiles.emptyTitle": "Keine Profile",
|
||||
"settings.profiles.emptyDescription": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden.",
|
||||
|
||||
"settings.general.section.general": "Allgemein",
|
||||
"settings.general.section.connection": "Verbindung",
|
||||
"settings.general.connectOnStartup.label": "Beim Start verbinden",
|
||||
@@ -153,8 +186,9 @@
|
||||
"settings.general.management.selfHosted": "Self-hosted",
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
"settings.general.management.switchCloudTitle": "Zu NetBird Cloud wechseln?",
|
||||
"settings.general.management.switchCloudMessage": "Dadurch wird die Verbindung zu Ihrem self-hosted Management-Server getrennt und eine neue Verbindung zu NetBird Cloud hergestellt. Möglicherweise müssen Sie sich erneut anmelden.",
|
||||
"settings.general.management.switchCloudMessage": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden.",
|
||||
"settings.general.management.switchCloudConfirm": "Zu Cloud wechseln",
|
||||
|
||||
"settings.network.section.connectivity": "Konnektivität",
|
||||
@@ -169,6 +203,8 @@
|
||||
"settings.network.clientRoutes.help": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen.",
|
||||
"settings.network.serverRoutes.label": "Server-Routen aktivieren",
|
||||
"settings.network.serverRoutes.help": "Lokale Routen dieses Hosts an andere Peers ankündigen.",
|
||||
"settings.network.ipv6.label": "IPv6 aktivieren",
|
||||
"settings.network.ipv6.help": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden.",
|
||||
|
||||
"settings.security.section.firewall": "Firewall",
|
||||
"settings.security.section.encryption": "Verschlüsselung",
|
||||
@@ -207,6 +243,7 @@
|
||||
"settings.advanced.interfaceName.errorMac": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100).",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.port.error": "Gib einen Port zwischen {min} und {max} ein.",
|
||||
"settings.advanced.port.help": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet.",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.mtu.error": "Gib eine MTU zwischen {min} und {max} ein.",
|
||||
"settings.advanced.psk.label": "Pre-shared Key",
|
||||
@@ -308,6 +345,23 @@
|
||||
"window.title.sessionExpired": "Sitzung abgelaufen",
|
||||
"window.title.sessionExpiring": "Sitzung läuft ab",
|
||||
"window.title.updating": "Aktualisierung",
|
||||
"window.title.welcome": "Willkommen bei NetBird",
|
||||
|
||||
"welcome.title": "Suchen Sie NetBird in der Taskleiste",
|
||||
"welcome.description": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen.",
|
||||
"welcome.continue": "Weiter",
|
||||
"welcome.back": "Zurück",
|
||||
"welcome.management.title": "NetBird einrichten",
|
||||
"welcome.management.description": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben.",
|
||||
"welcome.management.cloud.title": "NetBird Cloud",
|
||||
"welcome.management.cloud.description": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig.",
|
||||
"welcome.management.selfHosted.title": "Selbst gehostet",
|
||||
"welcome.management.selfHosted.description": "Verbindung zu Ihrem eigenen Management-Server.",
|
||||
"welcome.management.urlLabel": "URL des Management-Servers",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
"welcome.management.checking": "Wird geprüft …",
|
||||
|
||||
"browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort",
|
||||
"browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?",
|
||||
@@ -320,10 +374,10 @@
|
||||
"sessionExpired.signIn": "Anmelden",
|
||||
|
||||
"sessionAboutToExpire.title": "Sitzung läuft bald ab",
|
||||
"sessionAboutToExpire.titleLater": "Sitzung läuft ab",
|
||||
"sessionAboutToExpire.description": "Ihre NetBird-Sitzung läuft in Kürze ab. Bleiben Sie verbunden, damit Ihre Geräte online bleiben.",
|
||||
"sessionAboutToExpire.descriptionLater": "Ihre NetBird-Sitzung läuft ab. Verlängern Sie jetzt, damit Ihre Geräte online bleiben.",
|
||||
"sessionAboutToExpire.stay": "Verbunden bleiben",
|
||||
"sessionAboutToExpire.titleLater": "Ihre Sitzung läuft ab",
|
||||
"sessionAboutToExpire.description": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich.",
|
||||
"sessionAboutToExpire.descriptionLater": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden.",
|
||||
"sessionAboutToExpire.stay": "Sitzung erneuern",
|
||||
"sessionAboutToExpire.logout": "Abmelden",
|
||||
"sessionAboutToExpire.expired": "Sitzung abgelaufen",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
|
||||
@@ -355,7 +409,6 @@
|
||||
"peers.status.disconnected": "Getrennt",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Ressourcen",
|
||||
"peers.details.more": "+{count} weitere",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass aktiviert",
|
||||
@@ -368,7 +421,6 @@
|
||||
"networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
|
||||
"networks.selected": "Ausgewählt",
|
||||
"networks.unselected": "Nicht ausgewählt",
|
||||
"networks.ips.more": "+{count} weitere",
|
||||
"networks.ips.heading": "Aufgelöste IPs",
|
||||
"networks.bulk.selectionCount": "{selected} von {total} aktiv",
|
||||
"networks.bulk.enableAll": "Alle aktivieren",
|
||||
@@ -378,6 +430,11 @@
|
||||
"exitNodes.none": "Keiner",
|
||||
"exitNodes.empty.title": "Keine Exit Nodes verfügbar",
|
||||
"exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
|
||||
"exitNodes.card.title": "Exit Node",
|
||||
"exitNodes.card.statusActive": "Aktiv",
|
||||
"exitNodes.card.statusInactive": "Inaktiv",
|
||||
"exitNodes.dropdown.noneTitle": "Keiner",
|
||||
"exitNodes.dropdown.noneDescription": "Direkte Verbindung ohne Exit Node",
|
||||
|
||||
"quickActions.connect": "Verbinden",
|
||||
"quickActions.disconnect": "Trennen",
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
"common.saveChanges": "Save Changes",
|
||||
"common.saving": "Saving…",
|
||||
"common.close": "Close",
|
||||
"common.copy": "Copy",
|
||||
"common.togglePasswordVisibility": "Toggle password visibility",
|
||||
"common.increase": "Increase",
|
||||
"common.decrease": "Decrease",
|
||||
"common.delete": "Delete",
|
||||
"common.create": "Create",
|
||||
"common.add": "Add",
|
||||
@@ -105,23 +109,32 @@
|
||||
"profile.selector.moreOptions": "More options",
|
||||
"profile.selector.deregister": "Deregister",
|
||||
"profile.selector.delete": "Delete",
|
||||
"profile.selector.switchTo": "Switch to this profile",
|
||||
|
||||
"profile.dialog.title": "Enter Profile Name",
|
||||
"profile.dialog.description": "Choose a memorable name.",
|
||||
"profile.dialog.placeholder": "e.g. Work",
|
||||
"profile.dialog.nameLabel": "Profile Name",
|
||||
"profile.dialog.description": "Set an easily identifiable name for your profile.",
|
||||
"profile.dialog.placeholder": "e.g. work",
|
||||
"profile.dialog.submit": "Add Profile",
|
||||
"profile.dialog.required": "Please enter a profile name, e.g. Work, Home",
|
||||
"profile.dialog.required": "Please enter a profile name, e.g. work, home",
|
||||
"profile.dialog.managementHelp": "Use NetBird Cloud or your own server.",
|
||||
"profile.dialog.urlUnreachable": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.",
|
||||
|
||||
"header.menu.settings": "Settings...",
|
||||
"header.menu.defaultView": "Default View",
|
||||
"header.menu.advancedView": "Advanced View",
|
||||
"header.menu.updateAvailable": "Update Available",
|
||||
|
||||
"profile.deregister.title": "Deregister Profile",
|
||||
"profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.",
|
||||
"profile.switch.title": "Switch Profile to \"{name}\"?",
|
||||
"profile.switch.message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.",
|
||||
"profile.switch.confirm": "Confirm",
|
||||
"profile.deregister.title": "Deregister Profile \"{name}\"?",
|
||||
"profile.deregister.message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.",
|
||||
"profile.deregister.confirm": "Deregister",
|
||||
"profile.delete.title": "Delete Profile",
|
||||
"profile.delete.message": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"profile.delete.title": "Delete Profile \"{name}\"?",
|
||||
"profile.delete.message": "Are you sure you want to delete this profile?\nThis action cannot be undone.",
|
||||
"profile.delete.disabledActive": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.",
|
||||
"profile.delete.disabledDefault": "The default profile cannot be deleted.",
|
||||
"profile.error.switchTitle": "Switch Profile Failed",
|
||||
"profile.error.deregisterTitle": "Deregister Profile Failed",
|
||||
"profile.error.deleteTitle": "Delete Profile Failed",
|
||||
@@ -175,8 +188,9 @@
|
||||
"settings.general.management.selfHosted": "Self-hosted",
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlUnreachable": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.",
|
||||
"settings.general.management.switchCloudTitle": "Switch to NetBird Cloud?",
|
||||
"settings.general.management.switchCloudMessage": "This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
|
||||
"settings.general.management.switchCloudMessage": "This disconnects your self-hosted server.\nYou may need to log in again.",
|
||||
"settings.general.management.switchCloudConfirm": "Switch to Cloud",
|
||||
|
||||
"settings.network.section.connectivity": "Connectivity",
|
||||
@@ -191,6 +205,8 @@
|
||||
"settings.network.clientRoutes.help": "Accept routes from other peers to reach their networks.",
|
||||
"settings.network.serverRoutes.label": "Enable Server Routes",
|
||||
"settings.network.serverRoutes.help": "Advertise this host's local routes to other peers.",
|
||||
"settings.network.ipv6.label": "Enable IPv6",
|
||||
"settings.network.ipv6.help": "Use IPv6 addressing for the NetBird overlay network.",
|
||||
|
||||
"settings.security.section.firewall": "Firewall",
|
||||
"settings.security.section.encryption": "Encryption",
|
||||
@@ -225,8 +241,13 @@
|
||||
"settings.advanced.section.interface": "Interface",
|
||||
"settings.advanced.section.security": "Security",
|
||||
"settings.advanced.interfaceName.label": "Name",
|
||||
"settings.advanced.interfaceName.error": "Use 1-15 letters, digits, dots, hyphens, or underscores.",
|
||||
"settings.advanced.interfaceName.errorMac": "Must start with \"utun\" followed by a number (e.g. utun100).",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.port.error": "Enter a port between {min} and {max}.",
|
||||
"settings.advanced.port.help": "If set to 0, a random free port will be used.",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.mtu.error": "Enter an MTU value between {min} and {max}.",
|
||||
"settings.advanced.psk.label": "Pre-shared Key",
|
||||
"settings.advanced.psk.help": "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.",
|
||||
|
||||
@@ -245,7 +266,7 @@
|
||||
"settings.troubleshooting.duration.suffix": "Minute(s)",
|
||||
"settings.troubleshooting.create": "Create Bundle",
|
||||
"settings.troubleshooting.progress.description": "Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes.",
|
||||
"settings.troubleshooting.cancelling": "Cancelling…",
|
||||
"settings.troubleshooting.cancelling": "Canceling…",
|
||||
"settings.troubleshooting.done.uploadedTitle": "Debug bundle successfully uploaded!",
|
||||
"settings.troubleshooting.done.savedTitle": "Bundle saved",
|
||||
"settings.troubleshooting.done.uploadedDescription": "Share the upload key below with NetBird support. A local copy was also saved on your device.",
|
||||
@@ -261,7 +282,7 @@
|
||||
"settings.troubleshooting.stage.restoring": "Restoring previous log level…",
|
||||
"settings.troubleshooting.stage.bundling": "Generating debug bundle…",
|
||||
"settings.troubleshooting.stage.uploading": "Uploading to NetBird…",
|
||||
"settings.troubleshooting.stage.cancelling": "Cancelling…",
|
||||
"settings.troubleshooting.stage.cancelling": "Canceling…",
|
||||
|
||||
"settings.about.client": "NetBird Client v{version}",
|
||||
"settings.about.clientName": "NetBird Client",
|
||||
@@ -326,10 +347,28 @@
|
||||
"window.title.sessionExpired": "Session Expired",
|
||||
"window.title.sessionExpiring": "Session Expiring",
|
||||
"window.title.updating": "Updating",
|
||||
"window.title.welcome": "Welcome to NetBird",
|
||||
|
||||
"welcome.title": "Look for NetBird in your tray",
|
||||
"welcome.description": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||
"welcome.continue": "Continue",
|
||||
"welcome.back": "Back",
|
||||
"welcome.management.title": "Set up NetBird",
|
||||
"welcome.management.description": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.",
|
||||
"welcome.management.cloud.title": "NetBird Cloud",
|
||||
"welcome.management.cloud.description": "Use our hosted service. No setup required.",
|
||||
"welcome.management.selfHosted.title": "Self-hosted",
|
||||
"welcome.management.selfHosted.description": "Connect to your own management server.",
|
||||
"welcome.management.urlLabel": "Management server URL",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.",
|
||||
"welcome.management.checking": "Checking…",
|
||||
|
||||
"browserLogin.title": "Continue in your browser to complete the login",
|
||||
"browserLogin.notSeeing": "Not seeing the browser tab?",
|
||||
"browserLogin.tryAgain": "Try again",
|
||||
"browserLogin.openFailedTitle": "Open Browser Failed",
|
||||
|
||||
"sessionExpired.title": "Session expired",
|
||||
"sessionExpired.description": "Your NetBird session has expired. Sign in again to keep your devices connected.",
|
||||
@@ -337,13 +376,14 @@
|
||||
"sessionExpired.signIn": "Sign in",
|
||||
|
||||
"sessionAboutToExpire.title": "Session expiring soon",
|
||||
"sessionAboutToExpire.titleLater": "Session will expire",
|
||||
"sessionAboutToExpire.description": "Your NetBird session will expire shortly. Stay connected to keep your devices online.",
|
||||
"sessionAboutToExpire.descriptionLater": "Your NetBird session will expire. Extend now to keep your devices online.",
|
||||
"sessionAboutToExpire.stay": "Stay connected",
|
||||
"sessionAboutToExpire.titleLater": "Your session will expire",
|
||||
"sessionAboutToExpire.description": "This device will disconnect soon. Renew with a browser sign-in.",
|
||||
"sessionAboutToExpire.descriptionLater": "A browser sign-in keeps this device connected to your network.",
|
||||
"sessionAboutToExpire.stay": "Renew session",
|
||||
"sessionAboutToExpire.logout": "Logout",
|
||||
"sessionAboutToExpire.expired": "Session expired",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
|
||||
"sessionAboutToExpire.logoutFailedTitle": "Logout Failed",
|
||||
|
||||
"peers.search.placeholder": "Search by name or IP",
|
||||
"peers.filter.all": "All",
|
||||
@@ -371,7 +411,6 @@
|
||||
"peers.status.disconnected": "Disconnected",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Resources",
|
||||
"peers.details.more": "+{count} more",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass enabled",
|
||||
@@ -384,7 +423,6 @@
|
||||
"networks.empty.description": "No routed networks have been shared with this peer.",
|
||||
"networks.selected": "Selected",
|
||||
"networks.unselected": "Not selected",
|
||||
"networks.ips.more": "+{count} more",
|
||||
"networks.ips.heading": "Resolved IPs",
|
||||
"networks.bulk.selectionCount": "{selected} of {total} Active",
|
||||
"networks.bulk.enableAll": "Enable all",
|
||||
@@ -394,6 +432,11 @@
|
||||
"exitNodes.none": "None",
|
||||
"exitNodes.empty.title": "No exit nodes available",
|
||||
"exitNodes.empty.description": "No exit nodes have been shared with this peer.",
|
||||
"exitNodes.card.title": "Exit Node",
|
||||
"exitNodes.card.statusActive": "Active",
|
||||
"exitNodes.card.statusInactive": "Inactive",
|
||||
"exitNodes.dropdown.noneTitle": "None",
|
||||
"exitNodes.dropdown.noneDescription": "Direct connection without an exit node",
|
||||
|
||||
"quickActions.connect": "Connect",
|
||||
"quickActions.disconnect": "Disconnect",
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
"common.saveChanges": "Módosítások mentése",
|
||||
"common.saving": "Mentés…",
|
||||
"common.close": "Bezárás",
|
||||
"common.copy": "Másolás",
|
||||
"common.togglePasswordVisibility": "Jelszó láthatóságának váltása",
|
||||
"common.increase": "Növelés",
|
||||
"common.decrease": "Csökkentés",
|
||||
"common.delete": "Törlés",
|
||||
"common.create": "Létrehozás",
|
||||
"common.add": "Hozzáadás",
|
||||
@@ -95,6 +99,10 @@
|
||||
|
||||
"header.openSettings": "Beállítások megnyitása",
|
||||
"header.togglePanel": "Oldalsó panel váltása",
|
||||
"header.menu.settings": "Beállítások…",
|
||||
"header.menu.defaultView": "Alapnézet",
|
||||
"header.menu.advancedView": "Speciális nézet",
|
||||
"header.menu.updateAvailable": "Frissítés elérhető",
|
||||
|
||||
"profile.selector.loading": "Betöltés…",
|
||||
"profile.selector.noProfile": "Nincs profil",
|
||||
@@ -105,16 +113,33 @@
|
||||
"profile.selector.moreOptions": "További műveletek",
|
||||
"profile.selector.deregister": "Leválasztás",
|
||||
"profile.selector.delete": "Profil törlése",
|
||||
"profile.selector.switchTo": "Váltás erre a profilra",
|
||||
"profile.dropdown.activeProfile": "Aktív profil",
|
||||
"profile.dropdown.switchProfile": "Profilváltás",
|
||||
"profile.dropdown.noEmail": "Egyéb",
|
||||
"profile.dropdown.addProfile": "Profil hozzáadása",
|
||||
"profile.dropdown.manageProfiles": "Profilok kezelése",
|
||||
"profile.dropdown.settings": "Beállítások",
|
||||
|
||||
"profile.dialog.title": "Új profil",
|
||||
"profile.dialog.description": "A profilok lehetővé teszik, hogy különálló NetBird-kapcsolatokat tartson egymás mellett. Adjon profiljának egy könnyen megjegyezhető nevet.",
|
||||
"profile.dialog.nameLabel": "Profilnév",
|
||||
"profile.dialog.description": "Adjon profiljának egy könnyen azonosítható nevet.",
|
||||
"profile.dialog.placeholder": "pl. Munka",
|
||||
"profile.dialog.required": "Adjon meg egy profilnevet, pl. Munka, Otthon",
|
||||
"profile.dialog.submit": "Profil hozzáadása",
|
||||
"profile.dialog.managementHelp": "NetBird Cloud vagy saját kiszolgáló.",
|
||||
"profile.dialog.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes.",
|
||||
|
||||
"profile.deregister.title": "Profil leválasztása",
|
||||
"profile.deregister.message": "Biztosan le szeretné választani a következőt: \"{name}\"? Újra be kell jelentkeznie a használatához.",
|
||||
"profile.switch.title": "Váltás a(z) \"{name}\" profilra?",
|
||||
"profile.switch.message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva.",
|
||||
"profile.switch.confirm": "Megerősítés",
|
||||
"profile.deregister.title": "\"{name}\" profil leválasztása?",
|
||||
"profile.deregister.message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához.",
|
||||
"profile.deregister.confirm": "Leválasztás",
|
||||
"profile.delete.title": "Profil törlése",
|
||||
"profile.delete.message": "Biztosan törölni szeretné a következőt: \"{name}\"? Ez a művelet nem vonható vissza.",
|
||||
"profile.delete.title": "\"{name}\" profil törlése?",
|
||||
"profile.delete.message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza.",
|
||||
"profile.delete.disabledActive": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt.",
|
||||
"profile.delete.disabledDefault": "Az alapértelmezett profil nem törölhető.",
|
||||
"profile.error.switchTitle": "Profilváltás sikertelen",
|
||||
"profile.error.deregisterTitle": "Leválasztás sikertelen",
|
||||
"profile.error.deleteTitle": "Profil törlése sikertelen",
|
||||
@@ -129,11 +154,19 @@
|
||||
"settings.tabs.network": "Hálózat",
|
||||
"settings.tabs.security": "Biztonság",
|
||||
"settings.tabs.ssh": "SSH",
|
||||
"settings.tabs.profiles": "Profilok",
|
||||
"settings.tabs.advanced": "Speciális",
|
||||
"settings.tabs.troubleshooting": "Hibaelhárítás",
|
||||
"settings.tabs.about": "Névjegy",
|
||||
"settings.tabs.updateAvailable": "Frissítés elérhető",
|
||||
|
||||
"settings.profiles.section.profiles": "Profilok",
|
||||
"settings.profiles.intro": "Tartson külön NetBird-identitásokat egymás mellett, például munkahelyi és személyes fiókokat, vagy különböző kezelőszervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat.",
|
||||
"settings.profiles.addProfile": "Profil hozzáadása",
|
||||
"settings.profiles.active": "Aktív",
|
||||
"settings.profiles.emptyTitle": "Nincsenek profilok",
|
||||
"settings.profiles.emptyDescription": "Hozzon létre egy profilt a NetBird kezelőszerverhez való csatlakozáshoz.",
|
||||
|
||||
"settings.general.section.general": "Általános",
|
||||
"settings.general.section.connection": "Kapcsolat",
|
||||
"settings.general.connectOnStartup.label": "Csatlakozás indításkor",
|
||||
@@ -153,8 +186,9 @@
|
||||
"settings.general.management.selfHosted": "Saját üzemeltetésű",
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes.",
|
||||
"settings.general.management.switchCloudTitle": "Átváltás a NetBird Cloudra?",
|
||||
"settings.general.management.switchCloudMessage": "Ez megszünteti a kapcsolatot a saját üzemeltetésű kezelőszerverrel, és újra csatlakozik a NetBird Cloudhoz. Lehet, hogy újra be kell jelentkeznie.",
|
||||
"settings.general.management.switchCloudMessage": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie.",
|
||||
"settings.general.management.switchCloudConfirm": "Váltás a felhőre",
|
||||
|
||||
"settings.network.section.connectivity": "Kapcsolódás",
|
||||
@@ -169,6 +203,8 @@
|
||||
"settings.network.clientRoutes.help": "Más társak útvonalainak elfogadása az ő hálózataik eléréséhez.",
|
||||
"settings.network.serverRoutes.label": "Szerver útvonalak engedélyezése",
|
||||
"settings.network.serverRoutes.help": "Ennek a gazdának a helyi útvonalainak meghirdetése más társak számára.",
|
||||
"settings.network.ipv6.label": "IPv6 engedélyezése",
|
||||
"settings.network.ipv6.help": "IPv6-címzés használata a NetBird overlay hálózathoz.",
|
||||
|
||||
"settings.security.section.firewall": "Tűzfal",
|
||||
"settings.security.section.encryption": "Titkosítás",
|
||||
@@ -207,6 +243,7 @@
|
||||
"settings.advanced.interfaceName.errorMac": "„utun” után számmal kezdődjön (pl. utun100).",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.port.error": "Adj meg egy portot {min} és {max} között.",
|
||||
"settings.advanced.port.help": "Ha 0-ra állítod, egy véletlenszerű szabad portot használ.",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.mtu.error": "Adj meg egy MTU értéket {min} és {max} között.",
|
||||
"settings.advanced.psk.label": "Pre-shared kulcs",
|
||||
@@ -308,6 +345,23 @@
|
||||
"window.title.sessionExpired": "Munkamenet lejárt",
|
||||
"window.title.sessionExpiring": "Munkamenet lejár",
|
||||
"window.title.updating": "Frissítés",
|
||||
"window.title.welcome": "Üdvözli a NetBird",
|
||||
|
||||
"welcome.title": "Keresse a NetBirdöt a tálcán",
|
||||
"welcome.description": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához.",
|
||||
"welcome.continue": "Folytatás",
|
||||
"welcome.back": "Vissza",
|
||||
"welcome.management.title": "NetBird beállítása",
|
||||
"welcome.management.description": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van.",
|
||||
"welcome.management.cloud.title": "NetBird Cloud",
|
||||
"welcome.management.cloud.description": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra.",
|
||||
"welcome.management.selfHosted.title": "Saját üzemeltetésű",
|
||||
"welcome.management.selfHosted.description": "Csatlakozás a saját menedzsmentszerveréhez.",
|
||||
"welcome.management.urlLabel": "Menedzsmentszerver URL",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes.",
|
||||
"welcome.management.checking": "Ellenőrzés…",
|
||||
|
||||
"browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez",
|
||||
"browserLogin.notSeeing": "Nem látja a böngésző fülét?",
|
||||
@@ -320,10 +374,10 @@
|
||||
"sessionExpired.signIn": "Bejelentkezés",
|
||||
|
||||
"sessionAboutToExpire.title": "A munkamenet hamarosan lejár",
|
||||
"sessionAboutToExpire.titleLater": "A munkamenet lejár",
|
||||
"sessionAboutToExpire.description": "A NetBird munkamenete hamarosan lejár. Maradjon csatlakoztatva, hogy az eszközei elérhetők maradjanak.",
|
||||
"sessionAboutToExpire.descriptionLater": "A NetBird munkamenete lejár. Hosszabbítsa meg most, hogy az eszközei elérhetők maradjanak.",
|
||||
"sessionAboutToExpire.stay": "Maradjon csatlakoztatva",
|
||||
"sessionAboutToExpire.titleLater": "A munkamenete lejár",
|
||||
"sessionAboutToExpire.description": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell.",
|
||||
"sessionAboutToExpire.descriptionLater": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt.",
|
||||
"sessionAboutToExpire.stay": "Munkamenet megújítása",
|
||||
"sessionAboutToExpire.logout": "Kijelentkezés",
|
||||
"sessionAboutToExpire.expired": "Munkamenet lejárt",
|
||||
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
|
||||
@@ -355,7 +409,6 @@
|
||||
"peers.status.disconnected": "Lecsatlakozva",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Erőforrások",
|
||||
"peers.details.more": "+{count} további",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass engedélyezve",
|
||||
@@ -368,7 +421,6 @@
|
||||
"networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
|
||||
"networks.selected": "Kiválasztva",
|
||||
"networks.unselected": "Nincs kiválasztva",
|
||||
"networks.ips.more": "+{count} további",
|
||||
"networks.ips.heading": "Feloldott IP-címek",
|
||||
"networks.bulk.selectionCount": "{selected} / {total} aktív",
|
||||
"networks.bulk.enableAll": "Összes engedélyezése",
|
||||
@@ -378,6 +430,11 @@
|
||||
"exitNodes.none": "Egyik sem",
|
||||
"exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
|
||||
"exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
|
||||
"exitNodes.card.title": "Kilépő csomópont",
|
||||
"exitNodes.card.statusActive": "Aktív",
|
||||
"exitNodes.card.statusInactive": "Inaktív",
|
||||
"exitNodes.dropdown.noneTitle": "Egyik sem",
|
||||
"exitNodes.dropdown.noneDescription": "Közvetlen kapcsolat kilépő csomópont nélkül",
|
||||
|
||||
"quickActions.connect": "Csatlakozás",
|
||||
"quickActions.disconnect": "Bontás",
|
||||
|
||||
@@ -148,10 +148,26 @@ func main() {
|
||||
// (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close
|
||||
// so they don't linger as hidden windows that Wails's macOS dock-reopen
|
||||
// handler would pop back up.
|
||||
windowManager := services.NewWindowManager(app, window, bundle, prefStore)
|
||||
windowManager.WatchLanguage(prefStore)
|
||||
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
|
||||
// On minimal WMs (the in-process XEmbed-tray path) the WM neither centers
|
||||
// small windows nor restores their position across a hide -> show, so the
|
||||
// main/Settings windows would open in the top-left corner. Gate Go-side
|
||||
// re-centering on that environment; nil (full desktops, macOS, Windows)
|
||||
// leaves placement to the WM. See WindowManager.SetRecenterOnShow.
|
||||
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
|
||||
app.RegisterService(application.NewService(windowManager))
|
||||
|
||||
// Welcome / onboarding window. First launch only — the Continue
|
||||
// button in the dialog flips OnboardingCompleted=true via the
|
||||
// Preferences service before closing, so subsequent launches skip
|
||||
// straight to the tray-only flow. ApplicationStarted hook so the
|
||||
// Wails window machinery is fully up before the window is created.
|
||||
if !prefStore.Get().OnboardingCompleted {
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
windowManager.OpenWelcome()
|
||||
})
|
||||
}
|
||||
|
||||
// Register an in-process StatusNotifierWatcher so the tray works on
|
||||
// minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the
|
||||
// AppIndicator extension) that don't ship one themselves. No-op on
|
||||
@@ -313,10 +329,14 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
initialWidth = 900
|
||||
}
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
Width: initialWidth,
|
||||
Height: 640,
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
Width: initialWidth,
|
||||
Height: services.WindowHeight,
|
||||
// Center on first show. Full DEs (GNOME/KDE) place small windows
|
||||
// centered by default, but minimal WMs (fluxbox et al, the XEmbed
|
||||
// tray path) drop new windows in the top-left corner unless asked.
|
||||
InitialPosition: application.WindowCentered,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
URL: "/",
|
||||
|
||||
@@ -68,8 +68,9 @@ func (v ViewMode) IsValid() bool {
|
||||
// frontend. Pointer-free because the whole document is rewritten on every
|
||||
// change — there are no per-field partial updates.
|
||||
type UIPreferences struct {
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
}
|
||||
|
||||
// LanguageValidator is the dependency Store needs to reject SetLanguage
|
||||
@@ -162,6 +163,28 @@ func (s *Store) SetViewMode(mode ViewMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-window dismissal so the
|
||||
// welcome flow doesn't run again on subsequent launches. Idempotent — a
|
||||
// repeat of the current value is a no-op (no disk write, no broadcast).
|
||||
func (s *Store) SetOnboardingCompleted(done bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.OnboardingCompleted == done {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.OnboardingCompleted = done
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates and persists a new language preference, then
|
||||
// broadcasts the change to internal subscribers (tray) and the emitter
|
||||
// (frontend).
|
||||
|
||||
17
client/ui/recenter_linux.go
Normal file
17
client/ui/recenter_linux.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build linux && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
// recenterOnShowPredicate returns the predicate WindowManager uses to decide
|
||||
// whether to re-center its Go-shown windows (main, Settings) on each show.
|
||||
//
|
||||
// On Linux this is xembedTrayAvailable: re-centering is needed only in the
|
||||
// minimal-WM / in-process-XEmbed-tray environment, where the window manager
|
||||
// neither centers small windows for us nor restores their position across a
|
||||
// hide -> show round-trip. The predicate is evaluated per show (not once at
|
||||
// startup) because the XEmbed tray can appear after the UI starts — the panel
|
||||
// and the autostarted app race at login — and xembedTrayAvailable is a cheap,
|
||||
// side-effect-free selection-owner probe, fine to call repeatedly.
|
||||
func recenterOnShowPredicate() func() bool {
|
||||
return xembedTrayAvailable
|
||||
}
|
||||
12
client/ui/recenter_other.go
Normal file
12
client/ui/recenter_other.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !linux || (linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
// recenterOnShowPredicate returns nil off Linux (and on the cgo-less linux/386
|
||||
// build): macOS and Windows window managers center windows and restore their
|
||||
// position across hide -> show themselves, so the Go-side re-centering that
|
||||
// the minimal-WM Linux path needs would only fight a window the user moved.
|
||||
// A nil predicate makes WindowManager.centerWhenReady a no-op.
|
||||
func recenterOnShowPredicate() func() bool {
|
||||
return nil
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
@@ -339,5 +341,17 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
if _, err = cli.Logout(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
|
||||
// The daemon runs as root and can't reach the user-owned per-profile state
|
||||
// file that holds the account email (see Profiles.List). Drop it here from
|
||||
// the UI process so a logged-out profile no longer shows a stale email; the
|
||||
// next SSO login recreates it.
|
||||
if p.ProfileName != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
|
||||
// Non-fatal: the logout itself succeeded.
|
||||
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -46,6 +46,21 @@ const (
|
||||
// tray (Go side) so the frontend stays passive on this flow.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// MetadataKindProfileListChanged is the SystemEvent.metadata["kind"]
|
||||
// marker the daemon stamps on the INFO/SYSTEM event it publishes after a
|
||||
// CLI-driven AddProfile / RemoveProfile (the daemon emits no dedicated
|
||||
// profile RPC event). dispatchSystemEvent recognises it and re-emits the
|
||||
// existing EventProfileChanged so the tray and React profile views refresh
|
||||
// — closing the gap the SubscribeStatus path can't, since a profile
|
||||
// add/remove doesn't change the daemon's status string (the tray's
|
||||
// iconChanged guard would swallow it). The daemon side hard-codes the same
|
||||
// string literal in client/server/server.go (client/server cannot import
|
||||
// this UI package).
|
||||
MetadataKindProfileListChanged = "profile-list-changed"
|
||||
// metadataKindKey is the SystemEvent.metadata key the "kind" marker lives
|
||||
// under. Kept in sync with the daemon-side literal in client/server.
|
||||
metadataKindKey = "kind"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
|
||||
// daemon's gRPC socket is unreachable (daemon not running, socket
|
||||
// permission, etc.). Real daemon statuses come straight from
|
||||
@@ -526,6 +541,16 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error {
|
||||
func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
|
||||
se := systemEventFromProto(ev)
|
||||
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
|
||||
// A CLI-driven profile add/remove publishes a marked SYSTEM event purely
|
||||
// to nudge the UI's profile views. Translate it into the existing
|
||||
// EventProfileChanged (which the tray's loadProfiles and React's
|
||||
// ProfileContext.refresh already subscribe to) and stop — it's an internal
|
||||
// refresh signal, not a user-facing notification, so it must not reach the
|
||||
// Recent Events list or fire an OS toast.
|
||||
if se.Metadata[metadataKindKey] == MetadataKindProfileListChanged {
|
||||
s.emitter.Emit(EventProfileChanged, ProfileRef{})
|
||||
return
|
||||
}
|
||||
s.emitter.Emit(EventDaemonNotification, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// DebugBundleParams configures what the daemon collects when generating a
|
||||
@@ -55,6 +56,7 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
|
||||
@@ -36,3 +36,8 @@ func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) err
|
||||
func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error {
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-flow dismissal flag.
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
@@ -45,6 +46,10 @@ const EventSettingsOpen = "netbird:settings:open"
|
||||
// at #181A1D / nb-gray-950, used by AppLayout's <html> background).
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29)
|
||||
|
||||
// WindowHeight is the shared frame height for the main window and the
|
||||
// Settings window so the right panel inside both ends up the same size.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
// Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21);
|
||||
// title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only
|
||||
@@ -88,6 +93,17 @@ func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// LinuxAppearanceOptions is the per-window Linux chrome shared by every
|
||||
// NetBird webview window. Icon shows up in the WM task list / minimised
|
||||
// state; WindowIsTranslucent is left off so the opaque background colour
|
||||
// paints reliably on compositors that fake translucency badly.
|
||||
func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
||||
return application.LinuxWindow{
|
||||
Icon: icon,
|
||||
WindowIsTranslucent: false,
|
||||
}
|
||||
}
|
||||
|
||||
// DialogWindowOptions is the baseline for every auxiliary dialog window
|
||||
// (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress).
|
||||
// All four share size (360x320), the no-resize / no-min / no-max chrome,
|
||||
@@ -96,7 +112,7 @@ func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
// this), and the shared background/Mac/Windows appearance. Callers fill
|
||||
// in per-dialog overrides (URL params, screen targeting, etc.) on the
|
||||
// returned value before passing it to Window.NewWithOptions.
|
||||
func DialogWindowOptions(name, title, url string) application.WebviewWindowOptions {
|
||||
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
|
||||
return application.WebviewWindowOptions{
|
||||
Name: name,
|
||||
Title: title,
|
||||
@@ -112,6 +128,7 @@ func DialogWindowOptions(name, title, url string) application.WebviewWindowOptio
|
||||
URL: url,
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,17 +149,30 @@ type WindowManager struct {
|
||||
mainWindow *application.WebviewWindow
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
linuxIcon []byte
|
||||
settings *application.WebviewWindow
|
||||
browserLogin *application.WebviewWindow
|
||||
sessionExpired *application.WebviewWindow
|
||||
sessionAboutToExpire *application.WebviewWindow
|
||||
installProgress *application.WebviewWindow
|
||||
welcome *application.WebviewWindow
|
||||
// hiddenForLogin remembers windows that were visible when the
|
||||
// BrowserLogin popup opened. They were Hide()n to keep focus on the
|
||||
// SSO flow without resorting to AlwaysOnTop, and are restored when
|
||||
// the BrowserLogin window closes (success or cancel).
|
||||
hiddenForLogin []application.Window
|
||||
mu sync.Mutex
|
||||
// recenterOnShow reports whether Go should re-center the Go-shown
|
||||
// windows (main, Settings) on each show. Only true in the minimal-WM /
|
||||
// in-process XEmbed-tray environment, where the WM neither centers small
|
||||
// windows for us nor restores their position across a hide -> show
|
||||
// round-trip. On full desktops (GNOME/KDE) the WM handles placement, so
|
||||
// re-centering is unnecessary and would fight a window the user moved —
|
||||
// there this stays nil and centerWhenReady is a no-op. Set by the Linux
|
||||
// startup path via SetRecenterOnShow; nil on macOS/Windows and in tests.
|
||||
// A predicate (not a bool) because the XEmbed tray can appear after the
|
||||
// UI starts (panel/app login race), so the answer is evaluated per show.
|
||||
recenterOnShow func() bool
|
||||
}
|
||||
|
||||
// title resolves a window-title i18n key in the user's current language.
|
||||
@@ -173,13 +203,33 @@ func (s *WindowManager) title(key string) string {
|
||||
// The Settings window is created here, hidden, so the first OpenSettings
|
||||
// call paints instantly instead of paying webview construction + asset load
|
||||
// at click time.
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs}
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
|
||||
// If the prefs implementation also exposes Subscribe (the runtime
|
||||
// *preferences.Store does), wire up a goroutine that re-titles every
|
||||
// live auxiliary window on language flip. Done here — instead of via
|
||||
// an exported WatchLanguage method on the service — so the Wails
|
||||
// binding generator doesn't try to expose a LanguageSubscriber-taking
|
||||
// method to the frontend (interface params can't round-trip through
|
||||
// JSON and would emit a generator warning).
|
||||
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
|
||||
ch, _ := sub.Subscribe()
|
||||
go func() {
|
||||
var last i18n.LanguageCode
|
||||
for p := range ch {
|
||||
if p.Language == "" || p.Language == last {
|
||||
continue
|
||||
}
|
||||
last = p.Language
|
||||
s.retitleAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
Width: 900,
|
||||
Height: 640,
|
||||
Height: WindowHeight,
|
||||
Hidden: true,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
@@ -187,8 +237,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
})
|
||||
// Hide on close instead of destroying — preserves in-window React state
|
||||
// across reopens. Mirrors the main window's close behaviour. Resetting
|
||||
@@ -203,31 +254,6 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
return s
|
||||
}
|
||||
|
||||
// WatchLanguage subscribes to UI preference changes and re-applies the
|
||||
// localised title to every live auxiliary window whenever the language
|
||||
// flips. The eagerly-created Settings window outlives its first paint, so
|
||||
// without this its title would stay frozen in the language it was created
|
||||
// in; the on-demand dialog windows (BrowserLogin, Session*, InstallProgress)
|
||||
// can also coexist with a Settings-driven language change. Safe to call
|
||||
// once at startup; subsequent calls overwrite the previous subscription.
|
||||
// No-op when sub is nil.
|
||||
func (s *WindowManager) WatchLanguage(sub LanguageSubscriber) {
|
||||
if sub == nil {
|
||||
return
|
||||
}
|
||||
ch, _ := sub.Subscribe()
|
||||
go func() {
|
||||
var last i18n.LanguageCode
|
||||
for p := range ch {
|
||||
if p.Language == "" || p.Language == last {
|
||||
continue
|
||||
}
|
||||
last = p.Language
|
||||
s.retitleAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// retitleAll re-applies the localised title to every currently-alive
|
||||
// auxiliary window. Reads the window pointers under s.mu so a concurrent
|
||||
// Open*/Close* can't observe a torn slice. SetTitle itself dispatches to
|
||||
@@ -244,6 +270,7 @@ func (s *WindowManager) retitleAll() {
|
||||
{s.sessionExpired, "window.title.sessionExpired"},
|
||||
{s.sessionAboutToExpire, "window.title.sessionExpiring"},
|
||||
{s.installProgress, "window.title.updating"},
|
||||
{s.welcome, "window.title.welcome"},
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, p := range wins {
|
||||
@@ -271,6 +298,10 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
s.settings.Show()
|
||||
s.settings.Focus()
|
||||
// Re-center on every open (minimal-WM only): like the main window,
|
||||
// Settings is hidden (not destroyed) on close, and a hide -> show
|
||||
// round-trip lands it back in the corner there unless re-centered.
|
||||
s.centerWhenReady(s.settings)
|
||||
}
|
||||
|
||||
// OpenBrowserLogin shows the SSO popup window, creating it on first use (and
|
||||
@@ -295,7 +326,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
screen = sc
|
||||
}
|
||||
}
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL)
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
// SSO popup deliberately is NOT always-on-top — the user moves
|
||||
// between the browser tab and our popup; pinning it would obscure
|
||||
// the browser at the moment they need to interact with it.
|
||||
@@ -320,7 +351,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
// First open: window is Hidden, the React side auto-sizes via
|
||||
// useAutoSizeWindow and calls Window.Show/Focus once content is
|
||||
// measured. Returning here avoids the snap from placeholder to
|
||||
// measured height.
|
||||
// measured height. centerWhenReady polls for that JS-driven show,
|
||||
// so it centers (minimal-WM only) whoever ends up calling Show.
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
return
|
||||
}
|
||||
if uri != "" {
|
||||
@@ -328,6 +361,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
}
|
||||
s.browserLogin.Show()
|
||||
s.browserLogin.Focus()
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
}
|
||||
|
||||
// hideOtherWindowsLocked hides every currently visible window except the one
|
||||
@@ -405,17 +439,19 @@ func (s *WindowManager) OpenSessionExpired() {
|
||||
defer s.mu.Unlock()
|
||||
if s.sessionExpired == nil {
|
||||
s.sessionExpired = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired"),
|
||||
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired", s.linuxIcon),
|
||||
)
|
||||
s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.sessionExpired = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.sessionExpired)
|
||||
return
|
||||
}
|
||||
s.sessionExpired.Show()
|
||||
s.sessionExpired.Focus()
|
||||
s.centerWhenReady(s.sessionExpired)
|
||||
}
|
||||
|
||||
// CloseSessionExpired destroys the session-expired window if open.
|
||||
@@ -440,18 +476,20 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
|
||||
startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds)
|
||||
if s.sessionAboutToExpire == nil {
|
||||
s.sessionAboutToExpire = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL),
|
||||
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL, s.linuxIcon),
|
||||
)
|
||||
s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.sessionAboutToExpire = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.sessionAboutToExpire)
|
||||
return
|
||||
}
|
||||
s.sessionAboutToExpire.SetURL(startURL)
|
||||
s.sessionAboutToExpire.Show()
|
||||
s.sessionAboutToExpire.Focus()
|
||||
s.centerWhenReady(s.sessionAboutToExpire)
|
||||
}
|
||||
|
||||
// CloseSessionAboutToExpire destroys the countdown warning window if open.
|
||||
@@ -486,7 +524,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
if s.installProgress == nil {
|
||||
s.hideOtherWindowsLocked("install-progress")
|
||||
s.installProgress = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL),
|
||||
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon),
|
||||
)
|
||||
s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -494,11 +532,13 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.installProgress)
|
||||
return
|
||||
}
|
||||
s.installProgress.SetURL(startURL)
|
||||
s.installProgress.Show()
|
||||
s.installProgress.Focus()
|
||||
s.centerWhenReady(s.installProgress)
|
||||
}
|
||||
|
||||
// CloseInstallProgress destroys the install-progress window if open.
|
||||
@@ -511,3 +551,117 @@ func (s *WindowManager) CloseInstallProgress() {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenWelcome shows the first-launch onboarding window. The React side
|
||||
// auto-sizes the window height to its content; the Continue button calls
|
||||
// Preferences.SetOnboardingCompleted(true) before closing so the flow
|
||||
// doesn't re-run. Singleton, destroyed on close. Created Hidden so the
|
||||
// React side can auto-size before paint.
|
||||
func (s *WindowManager) OpenWelcome() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.welcome == nil {
|
||||
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
|
||||
opts.Width = 420
|
||||
// Onboarding stays AlwaysOnTop (inherited from DialogWindowOptions)
|
||||
// so the user can't accidentally bury the first-launch flow behind
|
||||
// another window and lose track of how to finish setup.
|
||||
// Land in the middle of the user's primary display — the welcome
|
||||
// flow is identity-defining and shouldn't read as an incidental
|
||||
// dialog floating in a corner. WindowCentered + nil Screen
|
||||
// resolves against the primary display (see WebviewWindowOptions).
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
s.welcome = s.app.Window.NewWithOptions(opts)
|
||||
w := s.welcome
|
||||
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.welcome = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.welcome)
|
||||
return
|
||||
}
|
||||
s.welcome.Show()
|
||||
s.welcome.Focus()
|
||||
s.centerWhenReady(s.welcome)
|
||||
}
|
||||
|
||||
// CloseWelcome destroys the welcome window if open.
|
||||
func (s *WindowManager) CloseWelcome() {
|
||||
s.mu.Lock()
|
||||
w := s.welcome
|
||||
s.welcome = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenMain brings the main window forward. Used by the welcome Continue
|
||||
// button to hand off from onboarding to the regular UI without depending
|
||||
// on the tray.
|
||||
func (s *WindowManager) OpenMain() {
|
||||
s.ShowMain()
|
||||
}
|
||||
|
||||
// ShowMain brings the main window forward, centering it on each show (see
|
||||
// centerWhenReady). The single entry point every surface — tray, SIGUSR1,
|
||||
// welcome handoff — should use so the centering fix applies uniformly.
|
||||
func (s *WindowManager) ShowMain() {
|
||||
if s.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
s.mainWindow.Show()
|
||||
s.mainWindow.Focus()
|
||||
// Re-center on every show (minimal-WM only — see centerWhenReady). The
|
||||
// window is hidden (not destroyed) on close, and on a hide -> show
|
||||
// round-trip minimal WMs (the XEmbed tray path) re-place it in the
|
||||
// top-left corner rather than restoring its prior position, so
|
||||
// re-opening from the tray lands it in the corner again otherwise.
|
||||
s.centerWhenReady(s.mainWindow)
|
||||
}
|
||||
|
||||
// SetRecenterOnShow installs the predicate that gates Go-side re-centering of
|
||||
// the main and Settings windows (see the recenterOnShow field). The Linux
|
||||
// startup path passes xembedTrayAvailable so re-centering happens only in the
|
||||
// minimal-WM / in-process-XEmbed-tray environment; macOS/Windows and tests
|
||||
// leave it unset, making centerWhenReady a no-op.
|
||||
func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
|
||||
s.recenterOnShow = pred
|
||||
}
|
||||
|
||||
// centerWhenReady centers w once its native window actually exists — but only
|
||||
// in environments where the WM won't do it for us (recenterOnShow). On full
|
||||
// desktops the WM centers small windows and restores position across hide ->
|
||||
// show, so this returns immediately and never fights a user-moved window.
|
||||
//
|
||||
// Why it can't be a simple inline Center() after Show(): on Linux/GTK4 (Wails'
|
||||
// linux_cgo backend) Center() moves the window via raw X11 (window_move_x11),
|
||||
// which silently no-ops while the GdkSurface is still nil — and GTK4 realizes
|
||||
// the surface asynchronously on the main loop, *after* Show() returns. So an
|
||||
// immediate Center() races realization and lands in the top-left corner; the
|
||||
// minimal WMs this targets don't re-center for us, so it sticks.
|
||||
//
|
||||
// It also can't be deferred via InvokeAsync(w.Center): Center() itself hops to
|
||||
// the main thread with InvokeSync, so running it *on* the main thread would
|
||||
// deadlock. So we drive it from a background goroutine (Center() and Position()
|
||||
// are main-thread-safe off-thread for exactly that reason) and retry until the
|
||||
// move actually takes effect, which is the unambiguous signal that the surface
|
||||
// now exists: position() goes through X11 (window_get_position_x11) and reports
|
||||
// (0,0) while the surface is nil — so a non-zero post-Center position means the
|
||||
// centering landed. Bounded so a window that legitimately centers at the origin
|
||||
// (e.g. fills the monitor) can't spin forever.
|
||||
func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) {
|
||||
if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for i := 0; i < 50; i++ { // ~1s budget at 20ms steps
|
||||
w.Center()
|
||||
if x, y := w.Position(); x != 0 || y != 0 {
|
||||
return // move took effect -> surface is realized
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ type Tray struct {
|
||||
// connected, the last status string, the daemon version, the
|
||||
// routed-networks revision, and the post-connect login-trigger flag.
|
||||
// These are all written by applyStatus and read by the menu painters
|
||||
// (applyIcon, reapplyMenuState, refreshExitNodes' connected sample,
|
||||
// (applyIcon, relayoutMenu, refreshExitNodes' connected sample,
|
||||
// etc.). One mutex covers them because they change together on every
|
||||
// Status push.
|
||||
statusMu sync.Mutex
|
||||
@@ -170,8 +170,24 @@ type Tray struct {
|
||||
// callers.
|
||||
profileLoadMu sync.Mutex
|
||||
|
||||
// profilesMu guards the cached profile rows that relayoutMenu repaints
|
||||
// into a freshly built Profiles submenu. loadProfiles fetches and stores
|
||||
// them here; fillProfileSubmenu reads them. Kept separate from the live
|
||||
// submenu so a relayout (which throws the old submenu away) always has a
|
||||
// source of truth to repaint from without re-hitting the daemon.
|
||||
profilesMu sync.Mutex
|
||||
profiles []services.Profile
|
||||
profilesUser string
|
||||
|
||||
// menuMu serialises relayoutMenu — the full buildMenu + SetMenu cycle.
|
||||
// loadProfiles (under profileLoadMu) and refreshExitNodes (under
|
||||
// exitNodesRebuildMu) both drive a relayout from independent mutexes, and
|
||||
// applyLanguage drives one from the Localizer goroutine; without this guard
|
||||
// two relayouts could interleave their t.menu swap and SetMenu push.
|
||||
menuMu sync.Mutex
|
||||
|
||||
// exitNodesMu guards the t.exitNodes row cache so reading the cached
|
||||
// rows in reapplyMenuState (and tearing a copy off the slice for
|
||||
// rows in relayoutMenu (and tearing a copy off the slice for
|
||||
// Repaint) doesn't contend with status-push readers of statusMu.
|
||||
exitNodesMu sync.Mutex
|
||||
// exitNodes are the rows currently painted into the Exit Node
|
||||
@@ -196,7 +212,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
// the right locale — no English flash followed by a re-paint.
|
||||
loc: svc.Localizer,
|
||||
}
|
||||
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() })
|
||||
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
|
||||
t.tray = app.SystemTray.New()
|
||||
// Seed panel-theme detection (Linux only) before the first paint so the
|
||||
// initial icon already matches the panel's light/dark scheme; repaints
|
||||
@@ -293,6 +309,13 @@ func (t *Tray) ShowWindow() {
|
||||
if t.window == nil {
|
||||
return
|
||||
}
|
||||
// Route through WindowManager so the main window is centered on its
|
||||
// first show (see WindowManager.ShowMain) — minimal WMs (fluxbox, the
|
||||
// XEmbed tray path) otherwise drop it in the top-left corner.
|
||||
if t.svc.WindowManager != nil {
|
||||
t.svc.WindowManager.ShowMain()
|
||||
return
|
||||
}
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
@@ -309,17 +332,36 @@ func (t *Tray) applyLanguage() {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.tray.SetLabel(t.loc.T("tray.tooltip"))
|
||||
}
|
||||
t.menu = t.buildMenu()
|
||||
t.tray.SetMenu(t.menu)
|
||||
t.reapplyMenuState()
|
||||
t.relayoutMenu()
|
||||
}
|
||||
|
||||
// reapplyMenuState walks cached state and re-applies the visibility,
|
||||
// enablement and label mutations that applyStatus would have performed
|
||||
// since the last menu rebuild. Required after buildMenu because that
|
||||
// constructor returns items in their default (disconnected) shape. The
|
||||
// update menu item is re-applied by trayUpdater.applyLanguage.
|
||||
func (t *Tray) reapplyMenuState() {
|
||||
// relayoutMenu rebuilds the ENTIRE tray menu from scratch (buildMenu), repaints
|
||||
// the cached status/session/profile/exit-node state into the fresh items, and
|
||||
// pushes the whole tree with a single SetMenu. It is the only Linux path that
|
||||
// reliably propagates submenu changes.
|
||||
//
|
||||
// Why a full rebuild rather than mutating the existing submenu in place: on
|
||||
// KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first
|
||||
// time it is opened (GetLayout for that submenu id) and never re-fetches it on
|
||||
// a LayoutUpdated(parent=0) signal — so Clear()+Add() into the same submenu
|
||||
// container left the visible menu (and, worse, the click→id mapping) frozen on
|
||||
// the first snapshot: clicks sent the stale ids, which the freshly-rebuilt
|
||||
// itemMap no longer knew, so they silently no-op'd. buildMenu allocates a brand
|
||||
// new submenu container id every time, which Plasma treats as an unseen menu
|
||||
// and re-queries on next open — both the labels and the click ids stay live.
|
||||
// (Confirmed via dbus-monitor: a re-opened submenu issued no GetLayout until
|
||||
// its container id changed.) The darwin detached-NSMenu workaround that the old
|
||||
// per-submenu SetMenu addressed is also covered, since this rebuilds the whole
|
||||
// tree against the cached top-level pointer.
|
||||
//
|
||||
// Pulls profile/exit-node rows from their caches (profilesMu / exitNodes) so it
|
||||
// never re-hits the daemon and never recurses back into loadProfiles.
|
||||
func (t *Tray) relayoutMenu() {
|
||||
t.menuMu.Lock()
|
||||
defer t.menuMu.Unlock()
|
||||
|
||||
t.menu = t.buildMenu()
|
||||
|
||||
t.statusMu.Lock()
|
||||
connected := t.connected
|
||||
lastStatus := t.lastStatus
|
||||
@@ -374,15 +416,19 @@ func (t *Tray) reapplyMenuState() {
|
||||
if t.updater != nil {
|
||||
t.updater.applyLanguage()
|
||||
}
|
||||
// buildMenu just recreated an empty Exit Node submenu, so repaint the
|
||||
// cached rows unconditionally (a refreshExitNodes would skip the rebuild
|
||||
// when the list is unchanged). Hold exitNodesRebuildMu so this rebuild
|
||||
// can't race a status-push-driven refreshExitNodes mutating the same
|
||||
// submenu.
|
||||
t.exitNodesRebuildMu.Lock()
|
||||
t.rebuildExitNodes(exitNodeEntries)
|
||||
t.exitNodesRebuildMu.Unlock()
|
||||
go t.loadProfiles()
|
||||
// buildMenu just recreated empty Profiles + Exit Node submenus, so repaint
|
||||
// both from their caches before the single SetMenu below. fillExitNodeSubmenu
|
||||
// uses the entries snapshotted above; fillProfileSubmenu reads profilesMu.
|
||||
// Neither re-fetches, so relayoutMenu never recurses back into
|
||||
// loadProfiles/refreshExitNodes. (We must NOT re-take exitNodesRebuildMu
|
||||
// here — refreshExitNodes already holds it when it calls relayoutMenu.)
|
||||
t.fillExitNodeSubmenu(exitNodeEntries)
|
||||
t.fillProfileSubmenu()
|
||||
|
||||
// Single push of the whole tree. On Linux this emits one LayoutUpdated with
|
||||
// fresh submenu container ids; on darwin it rebuilds the NSMenu against the
|
||||
// cached top-level pointer.
|
||||
t.tray.SetMenu(t.menu)
|
||||
}
|
||||
|
||||
func (t *Tray) buildMenu() *application.Menu {
|
||||
@@ -462,7 +508,7 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
|
||||
// exitNodeSubmenu hosts one row per peer advertising a default
|
||||
// route (0.0.0.0/0 or ::/0). Populated asynchronously by
|
||||
// rebuildExitNodes on every Status push that changes the set;
|
||||
// refreshExitNodes (via relayoutMenu) on every Status push that changes the set;
|
||||
// the parent row stays disabled until at least one candidate is
|
||||
// known. We grab the parent MenuItem via FindByLabel (same
|
||||
// pattern as the Profiles submenu) so applyStatus can flip its
|
||||
@@ -606,4 +652,3 @@ func (t *Tray) notify(title, body, id string) {
|
||||
func (t *Tray) notifyError(message string) {
|
||||
t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user