mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-09 17:39:57 +00:00
Compare commits
1 Commits
ui-refacto
...
ui-tray-li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfee5252a3 |
@@ -1,18 +0,0 @@
|
||||
# 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
|
||||
@@ -6,6 +6,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
iptables=1.8.9-2 \
|
||||
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
||||
xorg-dev=1:7.7+23 \
|
||||
libayatana-appindicator3-dev=0.5.92-1 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& go install -v golang.org/x/tools/gopls@latest
|
||||
|
||||
8
.github/workflows/golang-test-darwin.yml
vendored
8
.github/workflows/golang-test-darwin.yml
vendored
@@ -53,11 +53,5 @@ 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 -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)
|
||||
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)
|
||||
|
||||
- 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
|
||||
|
||||
64
.github/workflows/golang-test-linux.yml
vendored
64
.github/workflows/golang-test-linux.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||
|
||||
- name: Install 32-bit libpcap
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
${{ runner.os }}-gotest-cache-
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||
|
||||
- name: Install 32-bit libpcap
|
||||
if: matrix.arch == '386'
|
||||
@@ -166,15 +166,7 @@ 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 -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
|
||||
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)
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
@@ -292,17 +284,9 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test ${{ matrix.raceFlag }} \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-exec 'sudo' \
|
||||
-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]
|
||||
@@ -350,15 +334,7 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
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
|
||||
go test -timeout 10m -p 1 ./proxy/...
|
||||
|
||||
test_signal:
|
||||
name: "Signal / Unit"
|
||||
@@ -409,17 +385,9 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-exec 'sudo' \
|
||||
-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]
|
||||
@@ -485,18 +453,10 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
go test -tags=devcert \
|
||||
-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]
|
||||
@@ -735,14 +695,6 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=integration -coverprofile=coverage.txt \
|
||||
go test -tags=integration \
|
||||
-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
|
||||
|
||||
12
.github/workflows/golangci-lint.yml
vendored
12
.github/workflows/golangci-lint.yml
vendored
@@ -24,13 +24,9 @@ jobs:
|
||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||
# Non-English UI translations trip codespell on real foreign words
|
||||
# (de: "Sie", "oder", "ist"). Only en/common.json is the source of
|
||||
# truth that should be spell-checked. List each translated locale
|
||||
# dir below and add new ones as languages are added under
|
||||
# client/ui/i18n/locales/. Single-star globs are matched per path
|
||||
# segment by codespell and behave the same across versions; the
|
||||
# recursive "**" form did not take effect with the codespell shipped
|
||||
# by this action.
|
||||
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/hu/*
|
||||
# truth that should be spell-checked. Add each new locale dir here
|
||||
# when a language is added under client/ui/i18n/locales/.
|
||||
skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json,client/ui/i18n/locales/de/**,client/ui/i18n/locales/hu/**
|
||||
golangci:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -62,7 +58,7 @@ jobs:
|
||||
cache: false
|
||||
- name: Install dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||
- name: Stub Wails frontend bundle
|
||||
# client/ui/main.go has //go:embed all:frontend/dist. The
|
||||
# directory is produced by `pnpm run build` and is gitignored, so
|
||||
|
||||
39
.github/workflows/proto-version-check.yml
vendored
39
.github/workflows/proto-version-check.yml
vendored
@@ -20,30 +20,15 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
// 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');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
const baseSha = context.payload.pull_request.base.sha;
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
|
||||
@@ -70,22 +55,20 @@ jobs:
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of changedPbFiles) {
|
||||
for (const file of modifiedPbFiles) {
|
||||
const [base, head] = await Promise.all([
|
||||
getVersionHeader(file.basePath, baseSha),
|
||||
getVersionHeader(file.headPath, headSha),
|
||||
getVersionHeader(file.filename, baseSha),
|
||||
getVersionHeader(file.filename, headSha),
|
||||
]);
|
||||
if (!base.ok || !head.ok) {
|
||||
core.warning(
|
||||
`Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
`Skipping ${file.filename}: 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.basePath === file.headPath
|
||||
? file.headPath
|
||||
: `${file.basePath} → ${file.headPath}`,
|
||||
file: file.filename,
|
||||
base: base.lines,
|
||||
head: head.lines,
|
||||
});
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -367,10 +367,7 @@ jobs:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
# GTK4/WebKitGTK 6.0 dev libs for the default build + GTK3/WebKit2GTK 4.1
|
||||
# dev libs for the legacy -tags gtk3 build (netbird-ui-gtk3). Both stacks
|
||||
# coexist on the same runner; goreleaser builds both Linux variants here.
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64-x86-64
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev gcc-mingw-w64-x86-64
|
||||
|
||||
- name: Decode GPG signing key
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
2
.github/workflows/wasm-build-validation.yml
vendored
2
.github/workflows/wasm-build-validation.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||
- name: Install golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
with:
|
||||
|
||||
@@ -196,7 +196,6 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
@@ -211,7 +210,6 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_rpm
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
|
||||
@@ -24,25 +24,6 @@ builds:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). -tags gtk3 flips Wails to
|
||||
# the GTK3 stack and drops our GTK4-only XEmbed tray host (see
|
||||
# xembed_host_gtk3_linux.go). Removed upstream in Wails v3.1.
|
||||
- id: netbird-ui-gtk3
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=gtk3
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
- id: netbird-ui-windows-amd64
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
@@ -79,10 +60,6 @@ archives:
|
||||
name_template: "{{ .ProjectName }}-linux_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui
|
||||
- id: linux-gtk3-arch
|
||||
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
- id: windows-arch
|
||||
name_template: "{{ .ProjectName }}-windows_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
@@ -93,8 +70,6 @@ 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:
|
||||
@@ -105,19 +80,18 @@ nfpms:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- libgtk-4-1
|
||||
- libwebkitgtk-6.0-4
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- libayatana-appindicator3-1
|
||||
|
||||
- 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:
|
||||
@@ -128,66 +102,14 @@ nfpms:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
# Legacy GTK3 deb for Ubuntu 22.04 / Debian 12 (no WebKitGTK 6.0). Same
|
||||
# package_name as the GTK4 deb -- the two are mutually-exclusive alternatives
|
||||
# served from the matching distro repo; the package manager resolves by deps.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
# Legacy GTK3 rpm for RHEL 9 / Fedora <=39 (no WebKitGTK 6.0).
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- rpm
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
- libayatana-appindicator-gtk3
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
@@ -208,22 +130,3 @@ uploads:
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
# Legacy GTK3 packages share the netbird-ui name, so they must live in a repo
|
||||
# path the old distros point at -- here a dedicated `gtk3` distribution/path.
|
||||
# TODO: confirm the final repo layout with the pkgs.wiretrustee.com owner.
|
||||
- name: debian-gtk3
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
@@ -291,6 +291,8 @@ go test -exec sudo ./...
|
||||
```
|
||||
> On Windows use a powershell with administrator privileges
|
||||
|
||||
> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed
|
||||
|
||||
## Checklist before submitting a PR
|
||||
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
||||
- Keep functions as simple as possible, with a single purpose
|
||||
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
@@ -101,7 +100,6 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -300,7 +298,6 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -435,7 +432,6 @@ 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, consoleLog bool) (service.Service, error) {
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
||||
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||
SetFlagsFromEnvVars(serviceCmd)
|
||||
|
||||
@@ -112,14 +112,8 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
|
||||
return nil, 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)
|
||||
}
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := newSVCConfig()
|
||||
@@ -144,7 +138,7 @@ var runCmd = &cobra.Command{
|
||||
SetupCloseHandler(ctx, cancel)
|
||||
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
|
||||
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -158,7 +152,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, false)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,7 +170,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, false)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -194,7 +188,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, false)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,7 +206,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, true)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,13 +12,7 @@ var (
|
||||
Short: "Print the NetBird's client application version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
out := version.NetbirdVersion()
|
||||
if version.IsDevelopmentVersion(out) {
|
||||
if commit := version.NetbirdCommit(); commit != "" {
|
||||
out += "-" + commit
|
||||
}
|
||||
}
|
||||
cmd.Println(out)
|
||||
cmd.Println(version.NetbirdVersion())
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -362,10 +362,6 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,6 @@ const (
|
||||
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
|
||||
// can show "expires in ~N minutes" without hardcoding either constant.
|
||||
MetaSessionLeadMinutes = "lead_minutes"
|
||||
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
|
||||
// SystemEvent the daemon emits when it discards a deadline from the
|
||||
// management server (pre-epoch, too far in the future, or past the
|
||||
// clock-skew tolerance). The value is the rejection reason string.
|
||||
// userMessage is left empty; the UI detects the event via this key
|
||||
// and builds a localized notification — same pattern as the session
|
||||
// warnings above.
|
||||
MetaSessionDeadlineRejected = "session_deadline_rejected"
|
||||
)
|
||||
|
||||
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@@ -356,11 +355,6 @@ 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,8 +254,6 @@ 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
|
||||
@@ -280,8 +278,6 @@ type GeneratorDependencies struct {
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
ClientMetrics MetricsExporter
|
||||
DaemonVersion string
|
||||
CliVersion string
|
||||
}
|
||||
|
||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||
@@ -303,8 +299,6 @@ 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,
|
||||
@@ -465,11 +459,9 @@ 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,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
|
||||
statusReader := strings.NewReader(statusOutput)
|
||||
@@ -1047,8 +1039,7 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
return
|
||||
}
|
||||
|
||||
// This regex will match both logs rotated by us and logrotate on linux
|
||||
pattern := filepath.Join(logDir, "client*.log.*")
|
||||
pattern := filepath.Join(logDir, "client-*.log.gz")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
log.Warnf("failed to glob rotated logs: %v", err)
|
||||
@@ -1081,12 +1072,7 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
name := filepath.Base(files[i])
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
err = g.addSingleLogFileGz(files[i], name)
|
||||
} else {
|
||||
err = g.addSingleLogfile(files[i], name)
|
||||
}
|
||||
if err != nil {
|
||||
if err := g.addSingleLogFileGz(files[i], name); err != nil {
|
||||
log.Warnf("failed to add rotated log %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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,6 +22,7 @@ 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"
|
||||
@@ -55,7 +56,6 @@ 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,7 +72,6 @@ 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.
|
||||
@@ -149,10 +148,6 @@ 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.
|
||||
@@ -231,15 +226,10 @@ type Engine struct {
|
||||
|
||||
afpacketCapture *capture.AFPacketCapture
|
||||
|
||||
// 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
|
||||
|
||||
// Sync response persistence (protected by syncRespMux)
|
||||
syncRespMux sync.RWMutex
|
||||
persistSyncResponse bool
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// auto-update
|
||||
@@ -316,7 +306,6 @@ 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
|
||||
@@ -954,27 +943,26 @@ 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
|
||||
@@ -1106,7 +1094,6 @@ 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)
|
||||
|
||||
@@ -1185,7 +1172,6 @@ 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)
|
||||
},
|
||||
@@ -1858,18 +1844,6 @@ 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) {
|
||||
@@ -2212,42 +2186,45 @@ func (e *Engine) stopDNSServer() {
|
||||
e.statusRecorder.UpdateDNSStates(nsGroupStates)
|
||||
}
|
||||
|
||||
// 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).
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
||||
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
|
||||
e.syncRespMux.Lock()
|
||||
defer e.syncRespMux.Unlock()
|
||||
|
||||
if enabled == (e.syncStore != nil) {
|
||||
if enabled == e.persistSyncResponse {
|
||||
return
|
||||
}
|
||||
e.persistSyncResponse = enabled
|
||||
log.Debugf("Sync response persistence is set to %t", enabled)
|
||||
|
||||
if !enabled {
|
||||
if err := e.syncStore.Clear(); err != nil {
|
||||
log.Warnf("failed to clear persisted sync response: %v", err)
|
||||
}
|
||||
e.syncStore = nil
|
||||
return
|
||||
e.latestSyncResponse = nil
|
||||
}
|
||||
|
||||
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()
|
||||
defer e.syncRespMux.RUnlock()
|
||||
enabled := e.persistSyncResponse
|
||||
latest := e.latestSyncResponse
|
||||
e.syncRespMux.RUnlock()
|
||||
|
||||
if e.syncStore == nil {
|
||||
if !enabled {
|
||||
return nil, errors.New("sync response persistence is disabled")
|
||||
}
|
||||
|
||||
//nolint:nilnil
|
||||
return e.syncStore.Get()
|
||||
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
|
||||
}
|
||||
|
||||
// GetWgAddr returns the wireguard address
|
||||
@@ -2283,7 +2260,7 @@ func (e *Engine) updateDNSForwarder(
|
||||
enabled bool,
|
||||
fwdEntries []*dnsfwd.ForwarderEntry,
|
||||
) {
|
||||
if e.config.DisableServerRoutes || e.config.BlockInbound {
|
||||
if e.config.DisableServerRoutes {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
)
|
||||
|
||||
@@ -53,13 +51,6 @@ func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
||||
// of sync with the warning timers.
|
||||
if err := e.sessionWatcher.Update(deadline); err != nil {
|
||||
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
||||
e.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_ERROR,
|
||||
cProto.SystemEvent_AUTHENTICATION,
|
||||
"session deadline rejected",
|
||||
"",
|
||||
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,5 @@ func (w noopSessionWatcher) Update(deadline time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopSessionWatcher) Dismiss() {
|
||||
// No-op: only suppresses the timer-driven final-warning, which this stub never arms.
|
||||
}
|
||||
|
||||
func (noopSessionWatcher) Close() {
|
||||
// No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
|
||||
}
|
||||
func (noopSessionWatcher) Dismiss() {}
|
||||
func (noopSessionWatcher) Close() {}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
|
||||
nbversion "github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -13,7 +11,7 @@ var (
|
||||
)
|
||||
|
||||
func IsSupported(agentVersion string) bool {
|
||||
if nbversion.IsDevelopmentVersion(agentVersion) {
|
||||
if agentVersion == "development" {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ type LocalPeerState struct {
|
||||
PubKey string
|
||||
KernelInterface bool
|
||||
FQDN string
|
||||
WgPort int
|
||||
Routes map[string]struct{}
|
||||
}
|
||||
|
||||
@@ -335,12 +334,8 @@ 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. 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.
|
||||
// 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.
|
||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
if ip == "" {
|
||||
return State{}, false
|
||||
@@ -353,11 +348,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1528,7 +1518,6 @@ 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,28 +90,6 @@ 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,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
@@ -60,22 +59,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//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()
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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,6 +19,8 @@ import (
|
||||
|
||||
const (
|
||||
latestVersion = "latest"
|
||||
// this version will be ignored
|
||||
developmentVersion = "development"
|
||||
)
|
||||
|
||||
var errNoUpdateState = errors.New("no update state found")
|
||||
@@ -481,7 +483,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
|
||||
}
|
||||
|
||||
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
|
||||
if version.IsDevelopmentVersion(m.currentVersion) {
|
||||
if m.currentVersion == developmentVersion {
|
||||
log.Debugf("skipping auto-update, running development version")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1638,7 +1638,6 @@ 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
|
||||
}
|
||||
@@ -1729,13 +1728,6 @@ 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"`
|
||||
@@ -2753,7 +2745,6 @@ 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
|
||||
}
|
||||
@@ -2816,13 +2807,6 @@ 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"`
|
||||
@@ -6755,7 +6739,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"sshHostKey\x18\x13 \x01(\fR\n" +
|
||||
"sshHostKey\x12\x12\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
|
||||
"\x0eLocalPeerState\x12\x0e\n" +
|
||||
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
|
||||
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
|
||||
@@ -6764,8 +6748,7 @@ 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\x12\x16\n" +
|
||||
"\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
|
||||
"\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
|
||||
"\vSignalState\x12\x10\n" +
|
||||
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
|
||||
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
|
||||
@@ -6843,17 +6826,14 @@ 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\"\xb4\x01\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\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\x12\x1e\n" +
|
||||
"\n" +
|
||||
"cliVersion\x18\x06 \x01(\tR\n" +
|
||||
"cliVersion\"}\n" +
|
||||
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
|
||||
"\x13DebugBundleResponse\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
|
||||
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
|
||||
|
||||
@@ -384,7 +384,6 @@ message LocalPeerState {
|
||||
bool rosenpassPermissive = 6;
|
||||
repeated string networks = 7;
|
||||
string ipv6 = 8;
|
||||
int32 wgPort = 9;
|
||||
}
|
||||
|
||||
// SignalState contains the latest state of a signal connection
|
||||
@@ -513,7 +512,6 @@ message DebugBundleRequest {
|
||||
bool systemInfo = 3;
|
||||
string uploadURL = 4;
|
||||
uint32 logFileCount = 5;
|
||||
string cliVersion = 6;
|
||||
}
|
||||
|
||||
message DebugBundleResponse {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
#!/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.6.1
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
|
||||
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
|
||||
cd "$old_pwd"
|
||||
|
||||
@@ -14,7 +14,6 @@ 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.
|
||||
@@ -71,8 +70,6 @@ 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,22 +793,6 @@ 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)
|
||||
@@ -959,10 +943,6 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
|
||||
s.config = config
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
s.publishProfileListChanged(*msg.ProfileName)
|
||||
}
|
||||
|
||||
return &proto.SwitchProfileResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -1214,19 +1194,7 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
return mgmClient.Logout()
|
||||
}
|
||||
|
||||
// Status returns the daemon status
|
||||
@@ -1880,8 +1848,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1903,32 +1869,9 @@ 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()
|
||||
@@ -2133,15 +2076,3 @@ 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,7 +147,6 @@ 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"`
|
||||
@@ -197,7 +196,6 @@ 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(),
|
||||
@@ -571,21 +569,6 @@ 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"+
|
||||
@@ -599,7 +582,6 @@ 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"+
|
||||
@@ -608,8 +590,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"%s"+
|
||||
"Peers count: %s\n",
|
||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||
daemonVersion,
|
||||
cliVersion,
|
||||
o.DaemonVersion,
|
||||
version.NetbirdVersion(),
|
||||
o.ProfileName,
|
||||
managementConnString,
|
||||
signalConnString,
|
||||
@@ -619,7 +601,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
interfaceIP,
|
||||
ipv6Line,
|
||||
interfaceTypeString,
|
||||
wgPortString,
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
|
||||
@@ -94,7 +94,6 @@ 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",
|
||||
@@ -211,7 +210,6 @@ var overview = OutputOverview{
|
||||
IPv6: "fd00::100",
|
||||
PubKey: "Some-Pub-Key",
|
||||
KernelInterface: true,
|
||||
WgPort: 51820,
|
||||
FQDN: "some-localhost.awesome-domain.com",
|
||||
NSServerGroups: []NsServerGroupStateOutput{
|
||||
{
|
||||
@@ -371,7 +369,6 @@ 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,
|
||||
@@ -490,7 +487,6 @@ 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
|
||||
@@ -583,13 +579,12 @@ 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, overview.WgPort)
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
||||
|
||||
assert.Equal(t, expectedDetail, detail)
|
||||
}
|
||||
@@ -609,7 +604,6 @@ 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,8 +9,7 @@ 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 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_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||
- `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.
|
||||
@@ -37,9 +36,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` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `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). |
|
||||
| `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). |
|
||||
| `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, 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. |
|
||||
| `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. |
|
||||
| `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.
|
||||
@@ -94,13 +93,10 @@ The main window is created up front in `main.go`. Auxiliary windows are created
|
||||
|
||||
- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps the active tab in React local state and listens for the event to switch. **Reset-on-close lives in the React side**, not the Go close hook: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-fetched config.)
|
||||
- **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.
|
||||
- **SessionExpiration** (`/#/dialog/session-expiration?seconds=<n>`) — opened by `WindowManager.OpenSessionExpiration(seconds)`. 460×380, fixed size, `AlwaysOnTop: true`. The React-side buttons close the window via `WindowManager.CloseSessionExpiration` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Triggered by the tray today: `tray_session.go openSessionExpiration` fires it at T-FinalWarningLead when the earlier T-10 notification wasn't dismissed, and `openSessionExtendFlow` opens it on tray-row click seeded with the live remaining time. **Multi-monitor aware** — targets the display the OS cursor is currently on via `WindowManager.getScreenBasedOnCursorPosition`, which queries the native cursor location per-OS through `getCursorPosition` (`services/cursor_{darwin,windows,linux,other}.go`): `NSEvent.mouseLocation` flipped against the primary's frame height on macOS, `w32.GetCursorPos` + ScreenManager `PhysicalToDipPoint` on Windows, X11 `XQueryPointer` on Linux. The X11 query covers Wayland sessions too via XWayland, which ships by default on every supported Linux target. **Verified distro coverage**: Windows, macOS, Ubuntu 22.04 + 24.04 (GNOME-Wayland default + XWayland), Fedora 40 (GNOME-Wayland + XWayland), Debian 12 (GNOME default + XWayland), Arch Linux (any DE/compositor + XWayland), Linux Mint (Cinnamon-Xorg → Xorg direct), GNOME (Xorg + Wayland), Fluxbox (Xorg, exercised by the xembed-tray test path). Falls back gracefully (no panic, no error) to the main window's screen, then the OS default, when the cursor can't be resolved (headless / no DISPLAY / pure-Wayland-without-XWayland). Both first-create and re-show go through a single helper, `WindowManager.centerOnCursorScreen`: synchronous SetPosition first (covers full desktops and re-show with a still-alive GTK surface), then on minimal WMs (`recenterOnShow` — the Fluxbox/XEmbed-tray path) the same ~1s realize-detection retry loop `centerWhenReady` uses, because Wails' Linux SetPosition silently no-ops against a nil GdkSurface and Fluxbox would otherwise leave the window on the primary monitor.
|
||||
- **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()`.
|
||||
|
||||
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/dialogs.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
|
||||
|
||||
The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) 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.
|
||||
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.
|
||||
|
||||
On macOS, `main.go` overrides Wails' default `applicationShouldHandleReopen` listener (which shows *every* hidden window — see `pkg/application/events_common_darwin.go`) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one.
|
||||
|
||||
@@ -117,25 +113,25 @@ Package layout:
|
||||
- `client/ui/preferences/` — `Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter.
|
||||
- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`.
|
||||
|
||||
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpiration.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere.
|
||||
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpired.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere.
|
||||
|
||||
## Linux tray support
|
||||
|
||||
The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on minimal WMs is detailed in `LINUX-TRAY.md` (sibling). Touch that doc when modifying `tray_watcher_linux.go` / `xembed_host_linux.go` / `xembed_tray_linux.{c,h}`.
|
||||
|
||||
**Legacy `-tags gtk3` build:** Wails v3 defaults to GTK4/WebKitGTK 6.0; the legacy GTK3/WebKit2GTK 4.1 path (`-tags gtk3`, for Ubuntu 22.04 / Debian 12 / RHEL 9 / Fedora ≤39, removed upstream in Wails v3.1) is shipped as a second `netbird-ui` package built via `EXTRA_TAGS=gtk3` / a separate goreleaser lane. `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` are GTK4-only (`//go:build … && !gtk3`); on gtk3 builds `xembed_host_gtk3_linux.go` stubs them out (`xembedTrayAvailable()` → false), so the minimal-WM XEmbed fallback is **absent on gtk3** (tray still works on SNI-capable desktops). Keep the C files' `//go:build` constraints in sync with the Go file.
|
||||
|
||||
## Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — errors go through the custom Error window (see below), confirmations through the in-app `useConfirm()` modal. `WAILS-DIALOGS.md` (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (`OpenFile`/`SaveFile`) ever be needed.
|
||||
API surface — `Dialogs.Info` / `Warning` / `Error` / `Question` / `OpenFile` / `SaveFile`, options shape, per-OS behaviour, and the Go-side frameless-window pattern — lives in `WAILS-DIALOGS.md` (sibling). The conventions for **when** to use a native dialog vs inline UI are in the "Conventions" section below.
|
||||
|
||||
## Conventions in this codebase
|
||||
|
||||
### Errors → custom Error window
|
||||
### Errors → native dialogs
|
||||
|
||||
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/dialogs.ts`, which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean.
|
||||
User-actionable operation failures (config save, profile switch, debug bundle, update, etc.) surface via `Dialogs.Error` with an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong". The dialog itself already says "Error" visually.
|
||||
|
||||
**Skip dialogs entirely** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there.
|
||||
Confirmations use `Dialogs.Warning` with explicit `Buttons`. The promise resolves with the **button Label string**, not an index — pin the label into a variable before comparing (especially with i18n, where labels translate). Full API in `WAILS-DIALOGS.md`.
|
||||
|
||||
**Skip native dialogs** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no native dialog needed there.
|
||||
|
||||
### OS notifications
|
||||
|
||||
@@ -151,8 +147,6 @@ 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.
|
||||
|
||||
@@ -6,7 +6,3 @@ Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator e
|
||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
|
||||
## Legacy GTK3 build (`-tags gtk3`)
|
||||
|
||||
The XEmbed host (`xembed_host_linux.go` + `xembed_tray_linux.{c,h}`) hard-links GTK4 and uses GTK4-only popup-menu APIs (`GdkSurface`, `GtkEventControllerFocus`, `gtk_window_set_child`, `gdk_display_get_monitors`→`GListModel`, …), so it cannot compile against GTK3. On the legacy `-tags gtk3` build those files are excluded (`//go:build … && !gtk3`) and `xembed_host_gtk3_linux.go` provides a pure-Go stub where `xembedTrayAvailable()` returns false. The watcher probe then exits immediately, so the in-process XEmbed fallback is **absent on GTK3 builds** — the tray works only where the desktop ships its own `StatusNotifierWatcher` (KDE, GNOME+AppIndicator, Cinnamon/xapp, XFCE), not on minimal WMs. Rather than port the ~150-line C menu layer to GTK3 we accept this gap; `-tags gtk3` is removed upstream in Wails v3.1.
|
||||
|
||||
@@ -11,22 +11,8 @@ WebView.
|
||||
- `task`: `go install github.com/go-task/task/v3/cmd/task@latest`
|
||||
- A running NetBird daemon (default: `unix:///var/run/netbird.sock`,
|
||||
Windows `tcp://127.0.0.1:41731`)
|
||||
- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
|
||||
|
||||
### Legacy GTK3 build
|
||||
|
||||
Wails v3 builds on GTK4 / WebKitGTK 6.0 by default. Distros that don't ship
|
||||
WebKitGTK 6.0 yet (Ubuntu 22.04, Debian 12, RHEL 9, Fedora ≤ 39) need the
|
||||
legacy GTK3 / WebKit2GTK 4.1 build, produced with `-tags gtk3` (e.g.
|
||||
`task build EXTRA_TAGS=gtk3`). It needs `libgtk-3-dev` + `libwebkit2gtk-4.1-dev`
|
||||
instead of the GTK4 libs above. `-tags gtk3` is removed upstream in Wails v3.1.
|
||||
|
||||
> **Tray limitation:** the GTK3 build drops the in-process XEmbed
|
||||
> `StatusNotifierWatcher` (its menu layer is GTK4-only — see
|
||||
> [`LINUX-TRAY.md`](LINUX-TRAY.md) and `xembed_host_gtk3_linux.go`). The tray
|
||||
> still works on desktops that ship their own watcher (KDE, GNOME+AppIndicator,
|
||||
> Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/dwm)
|
||||
> is unavailable on GTK3 packages.
|
||||
- Linux only: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`,
|
||||
`libayatana-appindicator3-dev`
|
||||
|
||||
## Develop without rebuilding
|
||||
|
||||
|
||||
@@ -19,11 +19,10 @@ import (
|
||||
// side) so UI-side consumers don't have to import the daemon-internal
|
||||
// package directly.
|
||||
const (
|
||||
MetaWarning = sessionwatch.MetaSessionWarning
|
||||
MetaFinal = sessionwatch.MetaSessionFinal
|
||||
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
|
||||
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
|
||||
MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected
|
||||
MetaWarning = sessionwatch.MetaSessionWarning
|
||||
MetaFinal = sessionwatch.MetaSessionFinal
|
||||
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
|
||||
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
|
||||
)
|
||||
|
||||
// Warning is the typed payload emitted on the session-warning Wails
|
||||
|
||||
@@ -5,5 +5,4 @@ Icon=netbird
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
Keywords=netbird;
|
||||
StartupWMClass=org.wails.netbird
|
||||
Keywords=netbird;
|
||||
@@ -24,24 +24,24 @@ contents:
|
||||
- src: "./build/linux/netbird-ui.desktop"
|
||||
dst: "/usr/share/applications/netbird-ui.desktop"
|
||||
|
||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
|
||||
depends:
|
||||
- libgtk-4-1
|
||||
- libwebkitgtk-6.0-4
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
# Distribution-specific overrides for different package formats
|
||||
# Distribution-specific overrides for different package formats and WebKit versions
|
||||
overrides:
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
||||
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
|
||||
rpm:
|
||||
depends:
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
|
||||
# Arch Linux packages
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
|
||||
# Arch Linux packages (WebKit 4.1)
|
||||
archlinux:
|
||||
depends:
|
||||
- gtk4
|
||||
- webkitgtk-6.0
|
||||
- gtk3
|
||||
- webkit2gtk-4.1
|
||||
|
||||
# scripts section to ensure desktop database is updated after install
|
||||
scripts:
|
||||
|
||||
@@ -41,11 +41,6 @@ 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
|
||||
@@ -66,11 +61,9 @@ tasks:
|
||||
- cmd: rm -f *.syso
|
||||
platforms: [linux, darwin]
|
||||
vars:
|
||||
# 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}}'
|
||||
# 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"'
|
||||
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
|
||||
env:
|
||||
|
||||
@@ -25,15 +25,14 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window default route |
|
||||
| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
|
||||
| `/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-expiration` | `SessionExpirationDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpiration(seconds)`, always-on-top, mm:ss countdown via `?seconds=`). Drives both the soon-to-expire warning and (when seconds elapse to zero) the expired state. |
|
||||
| `/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()`. |
|
||||
| `/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=`) |
|
||||
| `/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. |
|
||||
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
In `app.tsx` the 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.
|
||||
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 (`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).
|
||||
`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).
|
||||
|
||||
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:
|
||||
@@ -52,25 +51,23 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- `modules/main/advanced/` — advanced-mode-only surfaces. `Navigation.tsx` plus the three feature sub-modules whose tabs only render here: `peers/`, `networks/`, `exit-nodes/`.
|
||||
- `modules/settings/` — `SettingsPage.tsx`, shared helpers (`SettingsSection.tsx`, `SettingsNavigation.tsx`, `SettingsSkeleton.tsx`), and all tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSSH`, `SettingsSecurity`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). `ManagementServerSwitch` and `LanguagePicker` are shared in `components/`; `useManagementUrl` is in `hooks/`.
|
||||
- `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window).
|
||||
- `modules/session/` — `SessionExpirationDialog.tsx` (session expiration warning + expired state).
|
||||
- `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/`. 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/error/` — `ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`.
|
||||
- `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.
|
||||
- `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`.
|
||||
|
||||
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`, `DialogContext`). 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`). 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` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly).
|
||||
- `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`.
|
||||
- `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`. 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.
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`.
|
||||
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
|
||||
|
||||
## Wails event bus
|
||||
@@ -157,7 +154,7 @@ Compare against the variable, never against an English literal.
|
||||
|
||||
**Language picker.** `src/components/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to.
|
||||
|
||||
**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpiration/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
|
||||
**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpired/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
|
||||
|
||||
## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`)
|
||||
|
||||
@@ -181,11 +178,9 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
|
||||
|
||||
## Dialogs convention
|
||||
|
||||
**Errors → `errorDialog({Title, Message})` from `src/lib/dialogs.ts`**, never `Dialogs.*` from `@wailsio/runtime` directly. Despite the name, `errorDialog` no longer opens a native OS MessageBox — it opens the custom always-on-top `/#/dialog/error` window via Go `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`). The `{Title, Message}` signature was kept so existing call sites read unchanged. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **Behaviour note:** `errorDialog()` resolves as soon as the window opens — it does *not* block until the user dismisses it, unlike the old native box; don't rely on the await pausing the flow.
|
||||
**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.
|
||||
|
||||
Why the native box is gone: on Windows a native `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence raced and left the window unable to process its close (X) button afterwards. The custom window never touches another window's enabled state, so that bug (and the old `Detached: true` Windows workaround) is gone. The unused native `warningDialog` / `infoDialog` / `questionDialog` wrappers were removed at the same time.
|
||||
|
||||
For **confirmations inside an app window** (the polished surfaces), use the in-app `useConfirm()` from `contexts/DialogContext.tsx` — `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`). **Skip** dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full convention rationale in `../CLAUDE.md`.
|
||||
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`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
@@ -201,7 +196,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
|
||||
## Things in flight (don't be surprised by)
|
||||
|
||||
- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `AppRightPanel` and is what the user sees today; the real-data one isn't wired into the route table.
|
||||
- **`modules/session/SessionExpirationDialog.tsx`** is the always-on-top auxiliary window for the SSO expiration warning. Triggered by the tray (`tray_session.go openSessionExpiration` at T-FinalWarningLead; `openSessionExtendFlow` from the "Expires in …" tray row). Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. When the countdown hits zero the same component flips to the "expired" copy (`sessionExpiration.expired*` keys).
|
||||
- **`modules/session/SessionExpiredDialog.tsx`** and **`modules/session/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. No triggers wired today — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
|
||||
|
||||
## Wails Go API reference
|
||||
|
||||
|
||||
@@ -166,12 +166,8 @@ Typical enforced-update flow on the `/update` route: call `Trigger` once, then p
|
||||
WindowManager.OpenSettings(): Promise<void>
|
||||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||||
WindowManager.OpenError(title: string, message: string): Promise<void> // custom branded error window; both query-escaped as ?title=…&message=…
|
||||
WindowManager.CloseError(): Promise<void>
|
||||
```
|
||||
|
||||
Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised.
|
||||
|
||||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||||
|
||||
## `I18n`
|
||||
|
||||
@@ -2,10 +2,9 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./globals.css";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
|
||||
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 ErrorDialog from "@/modules/error/ErrorDialog.tsx";
|
||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { MainPage } from "@/modules/main/MainPage.tsx";
|
||||
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
|
||||
@@ -38,9 +37,8 @@ Promise.all([
|
||||
<Route path="dialog">
|
||||
<Route path="browser-login" element={<LoginWaitingForBrowserDialog />} />
|
||||
<Route path="install-progress" element={<UpdateInProgressDialog />} />
|
||||
<Route path="session-expiration" element={<SessionExpirationDialog />} />
|
||||
<Route path="welcome" element={<WelcomeDialog />} />
|
||||
<Route path="error" element={<ErrorDialog />} />
|
||||
<Route path="session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
|
||||
5
client/ui/frontend/src/assets/flags/1x1/de.svg
Normal file
5
client/ui/frontend/src/assets/flags/1x1/de.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 232 B |
7
client/ui/frontend/src/assets/flags/1x1/en.svg
Normal file
7
client/ui/frontend/src/assets/flags/1x1/en.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 505 B |
7
client/ui/frontend/src/assets/flags/1x1/hu.svg
Normal file
7
client/ui/frontend/src/assets/flags/1x1/hu.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 273 B |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
@@ -2,15 +2,6 @@ import { useRef, useState, type ReactNode } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Static map — Tailwind JIT only picks up literal class names, so dynamic
|
||||
// template strings would be invisible to it.
|
||||
const VARIANT_HOVER = {
|
||||
default: "group-hover/copy:[&_*]:text-nb-gray-300",
|
||||
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
|
||||
} as const;
|
||||
|
||||
type CopyToClipboardVariant = keyof typeof VARIANT_HOVER;
|
||||
|
||||
type CopyToClipboardProps = {
|
||||
children: ReactNode;
|
||||
message?: string;
|
||||
@@ -19,11 +10,6 @@ type CopyToClipboardProps = {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// variant picks the text colour the wrapped content fades into on hover.
|
||||
// - "default" → nb-gray-300 (peer-details, settings, etc.)
|
||||
// - "bright" → nb-gray-200 (deeper-surface contexts like the main
|
||||
// connection card where text needs more lift)
|
||||
variant?: CopyToClipboardVariant;
|
||||
};
|
||||
|
||||
export const CopyToClipboard = ({
|
||||
@@ -34,7 +20,6 @@ export const CopyToClipboard = ({
|
||||
className,
|
||||
iconClassName,
|
||||
alwaysShowIcon = false,
|
||||
variant = "default",
|
||||
}: CopyToClipboardProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -58,22 +43,11 @@ export const CopyToClipboard = ({
|
||||
ref={wrapperRef}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable",
|
||||
"inline-flex gap-2 items-center group/copy cursor-pointer wails-no-draggable",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative truncate min-w-0",
|
||||
// [&_*] is Tailwind's arbitrary descendant variant: & is
|
||||
// this element, _ is the CSS descendant combinator, * is
|
||||
// every descendant. The generated selector has higher
|
||||
// specificity than a child's own text-nb-gray-* class, so
|
||||
// the hover colour wins the cascade.
|
||||
"[&_*]:transition-colors",
|
||||
VARIANT_HOVER[variant],
|
||||
)}
|
||||
>
|
||||
<span className={cn("relative truncate min-w-0")}>
|
||||
{children}
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -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, LanguagesIcon, Search } from "lucide-react";
|
||||
import { CheckIcon, ChevronDown, 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,17 +13,43 @@ import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// 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/
|
||||
// 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",
|
||||
});
|
||||
|
||||
const labelFor = (lang: Language): string =>
|
||||
lang.englishName && lang.englishName !== lang.displayName
|
||||
? `${lang.displayName} (${lang.englishName})`
|
||||
: lang.displayName;
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -95,9 +121,9 @@ export function LanguagePicker() {
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
|
||||
{current && <Flag code={current.code} label={current.displayName} />}
|
||||
<span className={"truncate flex-1 text-left"}>
|
||||
{current ? labelFor(current) : "—"}
|
||||
{current?.displayName ?? "—"}
|
||||
</span>
|
||||
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
@@ -167,8 +193,12 @@ export function LanguagePicker() {
|
||||
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
|
||||
)}
|
||||
>
|
||||
<span className={"flex-1 min-w-0 truncate"}>
|
||||
{labelFor(lang)}
|
||||
<Flag
|
||||
code={lang.code}
|
||||
label={lang.displayName}
|
||||
/>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{lang.displayName}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -7,28 +7,21 @@ 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, fullWidth = false }: Props) => {
|
||||
export const ManagementServerSwitch = ({ value, onChange }: 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} className={itemClass}>
|
||||
<SwitchItem value={ManagementMode.Cloud}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>
|
||||
{t("settings.general.management.selfHosted")}
|
||||
</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
|
||||
@@ -3,36 +3,18 @@ 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 tile with the provided lucide
|
||||
// icon centered inside. The `variant` selects the semantic colour scheme — all
|
||||
// variants keep the neutral dark tile + border; only the icon colour changes
|
||||
// to match the action's severity.
|
||||
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
default: "text-white",
|
||||
info: "text-sky-400",
|
||||
warning: "text-netbird",
|
||||
danger: "text-red-500",
|
||||
};
|
||||
|
||||
// (ConfirmDialog, etc.). Renders a bordered dark tile with the provided
|
||||
// lucide icon centered inside.
|
||||
type SquareIconProps = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
variant?: SquareIconVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SquareIcon = ({
|
||||
icon: Icon,
|
||||
iconSize = 18,
|
||||
variant = "default",
|
||||
className,
|
||||
}: SquareIconProps) => (
|
||||
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
|
||||
variantClass[variant],
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import * as RTooltip from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -9,15 +9,8 @@ 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 = ({
|
||||
@@ -27,35 +20,14 @@ 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);
|
||||
};
|
||||
|
||||
@@ -69,11 +41,10 @@ export const Tooltip = ({
|
||||
asChild
|
||||
onPointerEnter={() => {
|
||||
hoveringRef.current = true;
|
||||
cancelClose();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoveringRef.current = false;
|
||||
scheduleClose();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -83,19 +54,15 @@ 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 text-xs text-nb-gray-100 shadow-lg",
|
||||
"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",
|
||||
"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}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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-5 pr-0", className)}
|
||||
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -10,16 +10,12 @@ 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 cursor-default",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none",
|
||||
"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",
|
||||
],
|
||||
@@ -97,7 +93,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
xs: "text-xs py-2.5 px-3.5",
|
||||
xs2: "text-[0.78rem] py-[1.1rem] px-4 leading-[0]",
|
||||
xs2: "text-[0.78rem] py-2 px-4",
|
||||
sm: "text-sm py-[9px] px-4",
|
||||
md: "py-[9px] px-4",
|
||||
lg: "text-lg py-[9px] px-4",
|
||||
@@ -128,7 +124,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
onClick,
|
||||
disabled,
|
||||
copy,
|
||||
loading = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -139,7 +134,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant,
|
||||
@@ -164,16 +159,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{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>
|
||||
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@/lib/cn.ts";
|
||||
import { isMacOS } from "@/lib/platform.ts";
|
||||
|
||||
// ConfirmDialog is the shared layout wrapper used by dialog-style window
|
||||
// surfaces (SessionExpiration, …). Purely a layout
|
||||
// surfaces (SessionExpired, SessionAboutToExpire, …). Purely a layout
|
||||
// primitive — callers compose the contents (SquareIcon, DialogHeading,
|
||||
// DialogDescription, DialogActions) so each dialog can tweak its own
|
||||
// internal structure without growing the ConfirmDialog API.
|
||||
@@ -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 pt-6 pb-7",
|
||||
"flex flex-col items-center gap-5 text-center px-8 py-6",
|
||||
isMacOS() && "pt-10",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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 a native confirm dialog.
|
||||
//
|
||||
// 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,7 +2,6 @@ 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;
|
||||
@@ -16,7 +15,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/60",
|
||||
"bg-black/40 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"duration-150 ease-out",
|
||||
@@ -37,7 +36,6 @@ 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>
|
||||
@@ -69,7 +67,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={t("common.close")}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -3,32 +3,11 @@ 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, 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>
|
||||
export const DialogDescription = ({ children, className }: DialogDescriptionProps) => (
|
||||
<p className={cn("text-sm text-nb-gray-300 select-none", className)}>{children}</p>
|
||||
);
|
||||
|
||||
@@ -4,34 +4,13 @@ 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, 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,
|
||||
)}
|
||||
>
|
||||
export const DialogHeading = ({ children, className }: DialogHeadingProps) => (
|
||||
<p className={cn("text-base font-semibold text-nb-gray-50 select-none", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ComponentType } from "react";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { ExternalLinkIcon, LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
|
||||
@@ -7,20 +9,54 @@ type Props = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
description?: string;
|
||||
learnMoreUrl?: string;
|
||||
learnMoreTopic?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EmptyState = ({ icon, title, description, className }: Props) => {
|
||||
const openUrl = (url: string) => {
|
||||
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||
};
|
||||
|
||||
export const EmptyState = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
learnMoreUrl,
|
||||
learnMoreTopic,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-[7.8rem]"
|
||||
"flex flex-col items-center justify-center max-w-sm mx-auto relative top-7"
|
||||
}
|
||||
>
|
||||
<SquareIcon icon={icon} className={"mb-3"} />
|
||||
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
|
||||
<p className={"text-base 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"}>
|
||||
{t("common.learnMoreAbout")}{" "}
|
||||
<a
|
||||
href={learnMoreUrl}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openUrl(learnMoreUrl);
|
||||
}}
|
||||
className={cn(
|
||||
"text-netbird hover:underline underline-offset-4",
|
||||
"cursor-pointer wails-no-draggable",
|
||||
"inline-flex items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{learnMoreTopic}
|
||||
<ExternalLinkIcon size={12} className={"shrink-0"} />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.8rem]"}
|
||||
className={"relative -top-3.5"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,11 @@ import { EmptyState } from "./EmptyState";
|
||||
export const NotConnectedState = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={"relative w-full top-[3rem]"}>
|
||||
<div
|
||||
className={
|
||||
"h-full min-h-[260px] flex-1 flex items-center justify-center px-6 pb-20 top-1 relative"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={GlobeOffIcon}
|
||||
title={t("notConnected.title")}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -14,10 +13,6 @@ 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;
|
||||
@@ -38,10 +33,6 @@ 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: [
|
||||
@@ -62,7 +53,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
warning,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
@@ -72,7 +62,6 @@ 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";
|
||||
@@ -114,7 +103,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={t("common.togglePasswordVisibility")}
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
@@ -137,7 +126,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="hover:text-white transition-all pointer-events-auto"
|
||||
aria-label={t("common.copy")}
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
@@ -185,7 +174,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? "error" : warning ? "warning" : variant,
|
||||
variant: error ? "error" : 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",
|
||||
@@ -228,7 +217,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.increase")}
|
||||
aria-label="Increase"
|
||||
onClick={() => stepBy(1)}
|
||||
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
|
||||
>
|
||||
@@ -237,7 +226,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.decrease")}
|
||||
aria-label="Decrease"
|
||||
onClick={() => stepBy(-1)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
|
||||
@@ -249,14 +238,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(error || warning) && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs mt-2 inline-flex items-center gap-1",
|
||||
error ? "text-red-500" : "text-orange-400",
|
||||
)}
|
||||
>
|
||||
{error ?? warning}
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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 a native confirmation dialog. 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";
|
||||
export type NavSection = "peers" | "networks" | "exitNode";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
|
||||
@@ -13,7 +13,7 @@ import i18next from "@/lib/i18n";
|
||||
// measurements (content changes after mount) only adjust the size.
|
||||
//
|
||||
// Re-measures via ResizeObserver so adding/removing content (e.g. the
|
||||
// SessionExpiration title swapping at countdown zero) keeps the chrome
|
||||
// SessionAboutToExpire title swapping at countdown zero) keeps the chrome
|
||||
// tight to the content with no scrollbar.
|
||||
//
|
||||
// Also re-measures on i18next `languageChanged`. The ResizeObserver in
|
||||
@@ -22,16 +22,7 @@ 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.
|
||||
//
|
||||
// `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) {
|
||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
|
||||
const ref = useRef<T | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -40,7 +31,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
||||
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
|
||||
@@ -89,6 +79,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
||||
cancelAnimationFrame(raf2);
|
||||
i18next.off("languageChanged", scheduleApply);
|
||||
};
|
||||
}, [width, ready]);
|
||||
}, [width]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { warningDialog } from "@/lib/dialogs.ts";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useConfirm } from "@/contexts/DialogContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||
|
||||
// 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(
|
||||
function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
const URL_PATTERN = new RegExp(
|
||||
"^(https?:\\/\\/)?" +
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" +
|
||||
@@ -18,67 +27,17 @@ export const URL_PATTERN = new RegExp(
|
||||
"i",
|
||||
);
|
||||
|
||||
// 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 {
|
||||
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),
|
||||
@@ -86,11 +45,11 @@ export function useManagementUrl() {
|
||||
const [url, setUrl] = useState(
|
||||
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||
);
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
useEffect(() => {
|
||||
setModeState(modeFromUrl(config.managementUrl));
|
||||
@@ -99,27 +58,34 @@ export function useManagementUrl() {
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
// Clear the stale warning whenever the target changes.
|
||||
useEffect(() => {
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const setMode = async (next: ManagementMode) => {
|
||||
const setMode = (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 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);
|
||||
// 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;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setModeState(next);
|
||||
@@ -135,22 +101,7 @@ export function useManagementUrl() {
|
||||
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
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);
|
||||
};
|
||||
const save = () => saveField("managementUrl", targetUrl);
|
||||
|
||||
return {
|
||||
mode,
|
||||
@@ -161,7 +112,5 @@ export function useManagementUrl() {
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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.
|
||||
@@ -15,17 +14,15 @@ import { DialogProvider } from "@/contexts/DialogContext.tsx";
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
<DialogProvider>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
</StatusProvider>
|
||||
</DialogProvider>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
</StatusProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ type Props = {
|
||||
children: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
overlayOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// iOS-style push transition: incoming pane slides in from the right while
|
||||
@@ -17,14 +16,13 @@ const PANEL_TRANSITION = {
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-5",
|
||||
"wails-no-draggable relative m-4",
|
||||
"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
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
|
||||
// Options for errorDialog. Kept as a {Title, Message} object so the many
|
||||
// existing call sites read unchanged after the switch from the native OS
|
||||
// MessageBox to the custom window below.
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
import { isWindows } from "@/lib/platform";
|
||||
|
||||
// errorDialog surfaces a user-actionable failure. It opens the custom,
|
||||
// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx
|
||||
// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any
|
||||
// more, despite the name.
|
||||
// Derived from the runtime rather than deep-imported: the package's exports map
|
||||
// only exposes the types barrel, not "@wailsio/runtime/types/dialogs".
|
||||
type MessageDialogOptions = Parameters<typeof Dialogs.Error>[0];
|
||||
|
||||
// On Windows a native MessageBox attached to a parent window disables that
|
||||
// parent (WS_DISABLED) for the lifetime of the dialog 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 can race and
|
||||
// leave the window unable to process its close (X) button afterwards: the user
|
||||
// reports the main window can no longer be closed once an error dialog (e.g. a
|
||||
// rejected login) has been shown. Detaching the dialog gives the MessageBox a
|
||||
// NULL owner, so no window is ever disabled and the X keeps working.
|
||||
//
|
||||
// Why the native box is gone: on Windows a native MessageBox attached to a
|
||||
// parent window disables that window (WS_DISABLED) for its lifetime, and the
|
||||
// main window's WindowClosing hook hides instead of closing — the two raced
|
||||
// and could leave the main window unable to process its close (X) button after
|
||||
// an error was shown. The custom window has its own chrome and never touches
|
||||
// another window's enabled state, so that class of bug is gone (and with it
|
||||
// the old `Detached: true` Windows-only workaround, plus the warning/info/
|
||||
// question wrappers that nothing called).
|
||||
//
|
||||
// Title and message must already be localised. Resolves as soon as the window
|
||||
// is opened (it does not block until the user dismisses it), so `await`ing
|
||||
// callers continue immediately after the dialog appears.
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
// macOS keeps the attached (sheet-style) presentation — the bug is Windows-only
|
||||
// and detaching there loses the sheet animation — so we only force Detached on
|
||||
// Windows and leave any caller-supplied value untouched elsewhere.
|
||||
function withDetached(options: MessageDialogOptions): MessageDialogOptions {
|
||||
if (options.Detached !== undefined || !isWindows()) {
|
||||
return options;
|
||||
}
|
||||
return { ...options, Detached: true };
|
||||
}
|
||||
|
||||
export function errorDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Error(withDetached(options));
|
||||
}
|
||||
|
||||
export function warningDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Warning(withDetached(options));
|
||||
}
|
||||
|
||||
export function infoDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Info(withDetached(options));
|
||||
}
|
||||
|
||||
export function questionDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Question(withDetached(options));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
const short = typeof ce.short === "string" ? ce.short : "";
|
||||
const long = typeof ce.long === "string" ? ce.long : "";
|
||||
if (short && long && long !== short) {
|
||||
return `${short} Details: ${long}`;
|
||||
return `${short}\n\nDetails: ${long}`;
|
||||
}
|
||||
if (short) return short;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,3 @@ export const formatRelative = (
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
// shortenDns drops the domain suffix off a DNS name, returning just the
|
||||
// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain
|
||||
// is operator-configurable so we keep everything before the first dot rather
|
||||
// than matching against a known suffix. The full DNS name still lands on
|
||||
// the clipboard via the copy helpers' explicit message prop.
|
||||
export const shortenDns = (fqdn: string | undefined | null): string => {
|
||||
if (!fqdn) return "";
|
||||
const dot = fqdn.indexOf(".");
|
||||
return dot === -1 ? fqdn : fqdn.slice(0, dot);
|
||||
};
|
||||
|
||||
@@ -29,21 +29,17 @@ for (const path in bundleModules) {
|
||||
}
|
||||
|
||||
// detectBrowserLanguage walks navigator.language + navigator.languages
|
||||
// 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.
|
||||
// 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.
|
||||
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 lower = tag.toLowerCase();
|
||||
const exact = byLower.get(lower);
|
||||
if (exact) return exact;
|
||||
const base = byLower.get(lower.split("-")[0]);
|
||||
if (base) return base;
|
||||
const base = tag.toLowerCase().split("-")[0];
|
||||
if (available.includes(base)) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export const mockPeers: PeerStatus[] = [
|
||||
// eyeballing layout at maximum density.
|
||||
new PeerStatus({
|
||||
ip: "100.64.0.1",
|
||||
ipv6: "fd00:dead:beef::1",
|
||||
pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=",
|
||||
connStatus: "Connected",
|
||||
connStatusUpdateUnix: SECONDS(4),
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
|
||||
// ErrorDialog is the app's error surface — a frameless, always-on-top
|
||||
// NetBird-chromed window opened by WindowManager.OpenError(title, message),
|
||||
// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old
|
||||
// native OS MessageBox. Title and message arrive as query params (see
|
||||
// services/windowmanager.go errorDialogURL); both are caller-localised. The
|
||||
// title is also the window's chrome title ("NetBird - <title>", set Go-side);
|
||||
// it's repeated as the heading here so it stays visible on macOS, where the
|
||||
// hidden-inset title bar doesn't render the chrome title. The single Close
|
||||
// button (and the Escape key) dismisses the window via WindowManager.CloseError
|
||||
// — the Go side destroys it on close.
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const title = params.get("title") || t("window.title.error");
|
||||
const message = params.get("message") || "";
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Escape closes — keyboard-accessible cancellation, matching the native
|
||||
// dialog's behaviour. The primary button is autoFocused below so Enter
|
||||
// also dismisses.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading className={"text-balance"}>{title}</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={close}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -10,71 +10,45 @@ 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 { shortenDns } from "@/lib/formatters";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
// 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";
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
// 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;
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 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.
|
||||
let loginInFlight = false;
|
||||
async function startLogin(): Promise<void> {
|
||||
if (loginInFlight) return;
|
||||
loginInFlight = true;
|
||||
|
||||
let cancelled = false;
|
||||
let offCancel: (() => void) | undefined;
|
||||
let loginError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
@@ -90,6 +64,10 @@ async function startLogin(onSettled?: () => void): 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) {
|
||||
@@ -116,6 +94,12 @@ async function startLogin(onSettled?: () => void): 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;
|
||||
@@ -125,48 +109,17 @@ async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
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) {
|
||||
if (cancelled) return;
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(loginError),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
offCancel?.();
|
||||
loginInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -198,12 +151,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
if (loginGuard.current) return;
|
||||
loginGuard.current = true;
|
||||
setAction("logging-in");
|
||||
// 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(() => {
|
||||
void startLogin().finally(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
void refresh();
|
||||
@@ -390,22 +338,12 @@ export const MainConnectionStatusSwitch = () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
const show = connState === ConnectionState.Connected;
|
||||
const showLocal = connState === ConnectionState.Connected;
|
||||
const fqdn = status?.local.fqdn || "";
|
||||
const ip = status?.local.ip || "";
|
||||
const ipv6 = status?.local.ipv6 || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Anchored from the top so the FQDN/IP lines below the toggle
|
||||
// can grow into a popover-aware layout without shifting the
|
||||
// toggle itself (justify-center would slide everything up
|
||||
// when the IP line is hidden during Disconnected).
|
||||
"flex flex-col h-full w-full items-center gap-4",
|
||||
"relative top-[11.7rem]",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
@@ -431,145 +369,29 @@ export const MainConnectionStatusSwitch = () => {
|
||||
</h1>
|
||||
<CopyToClipboard
|
||||
message={fqdn}
|
||||
variant={"bright"}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300 max-w-full",
|
||||
"min-h-[1em] transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
show && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
showLocal && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<TruncatedText
|
||||
text={shortenDns(fqdn) || " "}
|
||||
className={
|
||||
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px]"
|
||||
}
|
||||
/>
|
||||
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
|
||||
{fqdn || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
|
||||
{ip || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has
|
||||
// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens
|
||||
// a popover containing both v4 and v6, each independently click-to-copy.
|
||||
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasV6 = !!ipv6;
|
||||
|
||||
if (!hasV6) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
variant={"bright"}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
|
||||
{ip || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300 max-w-full",
|
||||
"relative wails-no-draggable",
|
||||
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
className={cn(
|
||||
// relative so the chevron can be absolutely
|
||||
// positioned alongside without widening the trigger
|
||||
// — keeps the IP text centred in its parent and
|
||||
// lets the popover centre cleanly on it.
|
||||
"group relative inline-flex items-center outline-none cursor-default",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[0.8rem] leading-tight text-nb-gray-300 transition-colors",
|
||||
"group-hover:text-nb-gray-200",
|
||||
"group-data-[state=open]:text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{ip || " "}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={14}
|
||||
className={cn(
|
||||
"absolute -right-5 top-1/2 -translate-y-1/2",
|
||||
"shrink-0 text-nb-gray-300 transition-colors",
|
||||
"group-hover:text-nb-gray-200",
|
||||
"group-data-[state=open]:text-nb-gray-200",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side={"bottom"}
|
||||
align={"center"}
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 max-w-[280px] overflow-hidden",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-1 shadow-lg outline-none text-nb-gray-200",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
<IpRow value={ip} />
|
||||
<div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />
|
||||
<IpRow value={ipv6} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors
|
||||
// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy
|
||||
// icon on the right that flips to a checkmark briefly after a successful copy.
|
||||
const IpRow = ({ value }: { value: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleClick = async () => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group/iprow relative flex items-center justify-between gap-3",
|
||||
"rounded-md px-2 py-1.5 text-left",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"transition-colors outline-none cursor-default",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-[0.75rem] truncate min-w-0"}>{value}</span>
|
||||
<span className={"shrink-0 inline-flex items-center text-nb-gray-200"}>
|
||||
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
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,24 +143,21 @@ export const MainHeader = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 cursor-default wails-draggable relative z-10",
|
||||
"flex items-center h-12 top-3",
|
||||
"shrink-0 cursor-default wails-draggable relative",
|
||||
"flex items-center h-12 top-2.5",
|
||||
)}
|
||||
>
|
||||
{/* 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-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
|
||||
{settingsSlot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -10,6 +9,7 @@ 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,11 +37,8 @@ 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("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div className={cn("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>
|
||||
@@ -59,11 +56,7 @@ const AdvancedAppRightPanel = () => {
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
className={"m-5 ml-0"}
|
||||
>
|
||||
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
@@ -75,6 +68,7 @@ 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 } from "lucide-react";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -28,6 +28,11 @@ export const Navigation = () => {
|
||||
label: t("nav.resources.title"),
|
||||
icon: Layers3Icon,
|
||||
},
|
||||
{
|
||||
value: "exitNode",
|
||||
label: t("nav.exitNode.title"),
|
||||
icon: ExitNodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -67,4 +72,12 @@ 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";
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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,17 +39,23 @@ 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-200",
|
||||
"text-sm text-nb-gray-100",
|
||||
"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",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<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={"ml-0.5 shrink-0"} />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
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";
|
||||
@@ -77,7 +76,11 @@ 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");
|
||||
@@ -143,17 +146,27 @@ export const Networks = () => {
|
||||
|
||||
if (isConnected && networkRoutes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Layers3Icon}
|
||||
title={t("networks.empty.title")}
|
||||
description={t("networks.empty.description")}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title={t("networks.empty.title")}
|
||||
description={t("networks.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/networks"}
|
||||
learnMoreTopic={t("nav.resources.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -249,7 +262,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-9 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
@@ -258,21 +271,29 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={n.id}>
|
||||
<TruncatedText
|
||||
text={n.id}
|
||||
<span
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
/>
|
||||
>
|
||||
{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>
|
||||
@@ -286,7 +307,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
|
||||
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
@@ -306,12 +327,9 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
return (
|
||||
<div>
|
||||
<CopyToClipboard message={network.range}>
|
||||
<TruncatedText
|
||||
text={network.range}
|
||||
className={
|
||||
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{network.range}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
@@ -321,63 +339,90 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
};
|
||||
|
||||
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
|
||||
const span = (
|
||||
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
const first = ips[0];
|
||||
const extra = ips.length - 1;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
||||
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const extra = ips.length - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -405,7 +450,11 @@ 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,4 +1,12 @@
|
||||
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
@@ -8,8 +16,7 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
CableIcon,
|
||||
ClockIcon,
|
||||
GaugeIcon,
|
||||
HandshakeIcon,
|
||||
@@ -18,16 +25,16 @@ import {
|
||||
LucideProps,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
NetworkIcon,
|
||||
Radio,
|
||||
RefreshCwIcon,
|
||||
WaypointsIcon,
|
||||
ZapIcon,
|
||||
} 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, shortenDns } from "@/lib/formatters";
|
||||
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { mockOr, mockPeers } from "@/lib/mock";
|
||||
@@ -156,7 +163,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
iconClassName={"top-[2px]"}
|
||||
>
|
||||
<span className={"text-sm font-medium text-nb-gray-100 truncate"}>
|
||||
{shortenDns(selected.fqdn) || selected.ip}
|
||||
{selected.fqdn || selected.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<Tooltip content={t("peers.details.refresh")}>
|
||||
@@ -215,6 +222,7 @@ 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 (
|
||||
@@ -233,37 +241,12 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
{peer.ipv6 && (
|
||||
<Row icon={MapPinIcon} label={t("peers.details.netbirdIpv6")}>
|
||||
<CopyToClipboard
|
||||
message={peer.ipv6}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.ipv6} mono />
|
||||
</CopyToClipboard>
|
||||
</Row>
|
||||
)}
|
||||
{isConnected && (
|
||||
<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 icon={CableIcon} label={t("peers.details.connection")}>
|
||||
<span className={"inline-flex items-center gap-1.5 whitespace-nowrap"}>
|
||||
<ConnectionIcon size={13} />
|
||||
{connectionLabel}
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
{peer.latencyMs > 0 && (
|
||||
@@ -299,11 +282,6 @@ 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")}
|
||||
@@ -316,6 +294,27 @@ 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
|
||||
@@ -371,35 +370,67 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 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} />
|
||||
);
|
||||
// "{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>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
const ResourcesMorePopover = ({
|
||||
networks,
|
||||
extra,
|
||||
}: {
|
||||
networks: string[];
|
||||
extra: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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 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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{networks.length}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
{t("peers.details.more", { count: extra })}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -407,11 +438,13 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
side={"bottom"}
|
||||
align={"end"}
|
||||
sideOffset={6}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 max-w-[18rem] max-h-72 overflow-auto",
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 pr-4 shadow-lg outline-none",
|
||||
"p-2 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<ul className={"flex flex-col"}>
|
||||
@@ -420,7 +453,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
}
|
||||
>
|
||||
{n}
|
||||
@@ -435,15 +468,37 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
);
|
||||
};
|
||||
|
||||
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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
// 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 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-200",
|
||||
"text-sm text-nb-gray-100",
|
||||
"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,9 +47,15 @@ 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={"ml-0.5 shrink-0"} />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
@@ -62,10 +68,21 @@ 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,18 +1,17 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { latencyColor, shortenDns } from "@/lib/formatters";
|
||||
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";
|
||||
|
||||
@@ -68,55 +67,27 @@ export const Peers = () => {
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const stickyRef = useRef(false);
|
||||
const ordered = useMemo(() => {
|
||||
const sortOnlineFirst = (list: PeerStatus[]) =>
|
||||
[...list].sort((a, b) => {
|
||||
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 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);
|
||||
});
|
||||
|
||||
// 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,
|
||||
);
|
||||
})
|
||||
.map((p) => p.pubKey);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((k) => byKey.get(k)!);
|
||||
@@ -136,11 +107,19 @@ export const Peers = () => {
|
||||
|
||||
if (isConnected && peers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MonitorSmartphoneIcon}
|
||||
title={t("peers.empty.title")}
|
||||
description={t("peers.empty.description")}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={LaptopIcon}
|
||||
title={t("peers.empty.title")}
|
||||
description={t("peers.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/getting-started"}
|
||||
learnMoreTopic={t("nav.peers.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,12 +171,15 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 px-7 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",
|
||||
@@ -208,12 +190,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<TruncatedText
|
||||
text={shortenDns(peer.fqdn)}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
<TruncatedName name={peer.fqdn} />
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
@@ -247,3 +224,35 @@ 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,216 +1,79 @@
|
||||
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 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;
|
||||
onCreate: (name: 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 [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);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("");
|
||||
setNameError(null);
|
||||
setMode(ManagementMode.Cloud);
|
||||
setUrl("");
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
setChecking(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 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) => {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (checking) return;
|
||||
|
||||
const sanitized = sanitizeProfileInput(name);
|
||||
if (sanitized.length === 0) {
|
||||
setNameError(t("profile.dialog.required"));
|
||||
nameRef.current?.focus();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError(t("profile.dialog.required"));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
onCreate(trimmed);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(sanitizeProfileInput(value));
|
||||
if (nameError) setNameError(null);
|
||||
const handleChange = (value: string) => {
|
||||
setName(value);
|
||||
if (error) setError(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"
|
||||
showClose={false}
|
||||
className="py-7"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<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="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>
|
||||
|
||||
<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 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="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>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, 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,10 +11,6 @@ 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";
|
||||
@@ -27,50 +23,20 @@ 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);
|
||||
|
||||
// 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 sorted = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
@@ -87,53 +53,40 @@ export function ProfilesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
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 (!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"),
|
||||
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 },
|
||||
],
|
||||
});
|
||||
if (!ok) return;
|
||||
if (result !== confirmLabel) return;
|
||||
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (name === DEFAULT_PROFILE) return;
|
||||
const ok = await confirm({
|
||||
title: t("profile.delete.title", { name }),
|
||||
description: t("profile.delete.message", { name }),
|
||||
confirmLabel: t("common.delete"),
|
||||
danger: true,
|
||||
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 },
|
||||
],
|
||||
});
|
||||
if (!ok) return;
|
||||
if (result !== confirmLabel) return;
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
const handleCreate = async (name: 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({
|
||||
@@ -144,7 +97,7 @@ export function ProfilesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
@@ -155,12 +108,11 @@ export function ProfilesTab() {
|
||||
>
|
||||
<table className={"w-full text-sm"}>
|
||||
<tbody>
|
||||
{ordered.map((profile) => (
|
||||
{sorted.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSwitch={() => handleSwitch(profile.name)}
|
||||
onDeregister={() => handleDeregister(profile.name)}
|
||||
onDelete={() => handleDelete(profile.name)}
|
||||
/>
|
||||
@@ -168,7 +120,7 @@ export function ProfilesTab() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{loaded && ordered.length === 0 && (
|
||||
{loaded && sorted.length === 0 && (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center py-10 text-center"
|
||||
@@ -194,19 +146,18 @@ 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, onSwitch, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
|
||||
const showEmail = !!profile.email;
|
||||
@@ -241,11 +192,8 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
|
||||
</td>
|
||||
<td className={"px-4 py-2.5 text-right align-middle"}>
|
||||
<RowActions
|
||||
canSwitch={!isActive}
|
||||
canDeregister={!!profile.email}
|
||||
isDefault={profile.name === DEFAULT_PROFILE}
|
||||
isActive={isActive}
|
||||
onSwitch={onSwitch}
|
||||
canDelete={profile.name !== DEFAULT_PROFILE}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@@ -274,31 +222,14 @@ const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
};
|
||||
|
||||
type RowActionsProps = {
|
||||
canSwitch: boolean;
|
||||
canDeregister: boolean;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
onSwitch: () => void;
|
||||
canDelete: boolean;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowActions = ({
|
||||
canSwitch,
|
||||
canDeregister,
|
||||
isDefault,
|
||||
isActive,
|
||||
onSwitch,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowActionsProps) => {
|
||||
const RowActions = ({ canDeregister, canDelete, 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
|
||||
@@ -308,17 +239,11 @@ const RowActions = ({
|
||||
hidden={!canDeregister}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={deleteLabel}
|
||||
label={t("profile.selector.delete")}
|
||||
icon={Trash2}
|
||||
onClick={onDelete}
|
||||
variant={"danger"}
|
||||
disabled={deleteDisabled}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.switchTo")}
|
||||
icon={LogIn}
|
||||
onClick={onSwitch}
|
||||
hidden={!canSwitch}
|
||||
hidden={!canDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -332,8 +257,6 @@ 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 = ({
|
||||
@@ -342,15 +265,13 @@ const ActionIconButton = ({
|
||||
onClick,
|
||||
variant = "default",
|
||||
hidden = false,
|
||||
disabled = false,
|
||||
}: ActionIconButtonProps) => {
|
||||
const button = (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onClick={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",
|
||||
@@ -359,7 +280,6 @@ 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} />
|
||||
@@ -367,12 +287,7 @@ const ActionIconButton = ({
|
||||
);
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
|
||||
}
|
||||
side={"top"}
|
||||
>
|
||||
<Tooltip content={label} side={"top"}>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { AlertCircleIcon, ClockIcon } from "lucide-react";
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||
import {
|
||||
Connection,
|
||||
Profiles as ProfilesSvc,
|
||||
Session,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
@@ -36,7 +40,7 @@ function formatRemaining(seconds: number): string {
|
||||
return `${pad(minutes)}:${pad(secs)}`;
|
||||
}
|
||||
|
||||
export default function SessionExpirationDialog() {
|
||||
export default function SessionAboutToExpireDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
@@ -64,20 +68,6 @@ export default function SessionExpirationDialog() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [remaining]);
|
||||
|
||||
// Auto-close when the daemon flips back to Connected — covers extend
|
||||
// flows started from outside this window (tray notification action,
|
||||
// another UI surface) so the user isn't left staring at a stale dialog.
|
||||
useEffect(() => {
|
||||
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
|
||||
if (ev?.data?.status === "Connected") {
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
|
||||
// opens the browser for the user to sign in, blocks on the daemon until
|
||||
// the new deadline arrives. Tunnel stays up; success simply closes the
|
||||
@@ -110,10 +100,10 @@ export default function SessionExpirationDialog() {
|
||||
// relevant.
|
||||
return;
|
||||
}
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
WindowManager.CloseSessionAboutToExpire().catch(console.error);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Title: t("sessionAboutToExpire.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
@@ -131,10 +121,10 @@ export default function SessionExpirationDialog() {
|
||||
profileName: active.profileName || "default",
|
||||
username,
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
WindowManager.CloseSessionAboutToExpire().catch(console.error);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Title: t("sessionAboutToExpire.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
@@ -142,41 +132,33 @@ export default function SessionExpirationDialog() {
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
|
||||
<SquareIcon icon={ClockIcon} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading>
|
||||
{expired
|
||||
? t("sessionExpiration.expired")
|
||||
? t("sessionAboutToExpire.expired")
|
||||
: soon
|
||||
? t("sessionExpiration.title")
|
||||
: t("sessionExpiration.titleLater")}
|
||||
? t("sessionAboutToExpire.title")
|
||||
: t("sessionAboutToExpire.titleLater")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{expired
|
||||
? t("sessionExpiration.expiredDescription")
|
||||
: soon
|
||||
? t("sessionExpiration.description")
|
||||
: t("sessionExpiration.descriptionLater")}
|
||||
{soon
|
||||
? t("sessionAboutToExpire.description")
|
||||
: t("sessionAboutToExpire.descriptionLater")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
{!expired && (
|
||||
<div
|
||||
className={
|
||||
"font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider"
|
||||
}
|
||||
aria-live={"polite"}
|
||||
>
|
||||
{formatRemaining(remaining)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
"font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider"
|
||||
}
|
||||
aria-live={"polite"}
|
||||
>
|
||||
{formatRemaining(remaining)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
@@ -185,20 +167,18 @@ export default function SessionExpirationDialog() {
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={stay}
|
||||
disabled={busy}
|
||||
disabled={expired || busy}
|
||||
>
|
||||
{expired
|
||||
? t("sessionExpiration.authenticate")
|
||||
: t("sessionExpiration.stay")}
|
||||
{t("sessionAboutToExpire.stay")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={expired ? close : logout}
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
>
|
||||
{expired ? t("sessionExpiration.close") : t("sessionExpiration.logout")}
|
||||
{t("sessionAboutToExpire.logout")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
export default function SessionExpiredDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
|
||||
const signIn = useCallback(() => {
|
||||
void Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||
WindowManager.CloseSessionExpired().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const later = useCallback(() => {
|
||||
WindowManager.CloseSessionExpired().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<SquareIcon icon={AlertCircleIcon} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading>{t("sessionExpired.title")}</DialogHeading>
|
||||
<DialogDescription>{t("sessionExpired.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={signIn}
|
||||
>
|
||||
{t("sessionExpired.signIn")}
|
||||
</Button>
|
||||
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={later}>
|
||||
{t("sessionExpired.later")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -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-12rem)]"
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user