Compare commits

...

45 Commits

Author SHA1 Message Date
Zoltán Papp
21f1142355 Merge branch 'main' into ui-refactor
# Conflicts:
#	client/ui/debug.go
#	go.mod
#	go.sum
2026-06-05 17:50:18 +02:00
Eduard Gert
9ecc083139 update missing translations 2026-06-05 14:57:32 +02:00
Eduard Gert
efd874efac add cloud / selfhosted segment in profile creation 2026-06-05 14:38:00 +02:00
Eduard Gert
5877880789 replace native confirm dialog with modals in settings 2026-06-05 13:16:39 +02:00
Eduard Gert
4427aaa31f update advanced port margin, update settings bottom bar height 2026-06-05 12:29:18 +02:00
Zoltán Papp
0ce3fbf5af [client] Tolerate already-deleted peer on profile logout, clear stale email
logoutFromProfile failed hard when the management server returned NotFound
(peer already deleted from the dashboard), blocking both profile logout and
profile removal. Treat NotFound as success — the peer is already gone, so
deregistering it is already satisfied.

Also drop the user-side per-profile state file on logout. The account email is
sourced from <profile>.state.json (written by the CLI after SSO login), which
the root daemon can't reach, so logout left a stale email showing in the UI.
Connection.Logout now removes it from the UI process after a successful logout;
the next SSO login recreates it.
2026-06-05 12:05:41 +02:00
Zoltán Papp
3967864172 [client/ui] Center windows on show under minimal WMs (XEmbed tray)
On minimal window managers (fluxbox et al, the in-process XEmbed-tray
path) the WM neither centers small windows nor restores their position
across a hide -> show round-trip, so the main, Settings, and dialog
windows opened in the top-left corner instead of centered.

These windows are created Hidden, so Wails' Linux/GTK4 backend skips its
post-Show centering pass (gated on !Hidden) and InitialPosition has no
effect on an unrealized window. Re-center from Go after Show, gated on
the minimal-WM environment via a recenterOnShow predicate (set to
xembedTrayAvailable on Linux, nil on macOS/Windows where the WM handles
placement). centerWhenReady polls from a background goroutine until the
move actually lands -- Center() moves via raw X11, which no-ops while the
GdkSurface is still nil and GTK4 realizes it asynchronously after Show().

Also reorder xembed_host_linux.go so the static helpers (xembedTrayAvailable,
goMenuItemClicked) sit at the end, after the constructor and methods.
2026-06-05 11:42:03 +02:00
Eduard Gert
296d3f124b truncate dns in main screen 2026-06-05 09:55:50 +02:00
Eduard Gert
adf1fe1858 add ready prop to autosize hook 2026-06-05 09:48:17 +02:00
Zoltán Papp
1108808ab1 [client] Persist sync response only after network map is applied
Previously the SyncResponse was persisted to syncStore before
updateNetworkMap() ran. If applying the network map failed, the engine
persisted state it never applied, so GetLatestSyncResponse() could return
stale/unapplied state. Move the persistence into the post-apply success
path so the persisted response always reflects what the engine applied.
2026-06-04 18:31:37 +02:00
Zoltán Papp
db371a0263 [client/ui] Fix tray submenu not updating on KDE/Plasma
The Profiles and Exit Node submenus (and the About version/Update rows)
stopped reflecting changes on KDE/Plasma: after the first profile switch
the menu froze on its initial snapshot, and "Manage Profiles" — plus the
profile rows themselves — stopped responding to clicks entirely.

Root cause (confirmed via dbus-monitor): Plasma's StatusNotifierItem host
caches a submenu's layout the first time it is opened (GetLayout for that
submenu id) and never re-fetches it on a LayoutUpdated(parent=0) signal.
The old submenu.Clear()+Add() repaint allocated fresh monotonic item ids
each time but reused the same submenu container id, so Plasma kept showing
the stale snapshot and, on click, sent the stale ids back — which the
rebuilt itemMap no longer knew, silently no-op'ing the click.

Fix: route every dynamic tray-menu change through a new relayoutMenu that
rebuilds the whole tree (buildMenu + repaint cached state + a single
SetMenu), allocating brand-new submenu container ids. Plasma treats those
as unseen and re-queries them on next open, fixing both the stale paint
and the dead clicks. loadProfiles/refreshExitNodes now cache their rows
and drive relayoutMenu; the update row goes through a new onMenuChange
hook; the daemon-version row relayouts too. relayoutMenu is serialised by
menuMu and the fill*Submenu helpers are pure UI (no fetch, no SetMenu) so
it never recurses. The whole-tree SetMenu also subsumes the prior darwin
detached-NSMenu workaround.
2026-06-04 18:28:30 +02:00
Theodor Midtlien
512899d82d [client] Prevent corruption from competing log rotation and improve debug bundle (#6214)
* Adds heuristic to detect an edge case on Linux where a system has configured logrotate as a separate service to rotate log files which would mangle our client log files. If we detect logrotate being configured for netbird, we disable our rotation.

* Adds new env var to disable log rotation: NB_LOG_DISABLE_ROTATION

* Adds compressed and plain logrotate files to debug bundle.

* Replaces lumberjack with timberjack (maintained fork with bug fixes and extra features).

* Clarifies which daemon version is running in the bundle stats.

* Change logging for client service status to console
2026-06-04 17:36:45 +02:00
Zoltán Papp
1412b06999 Add rabbit ai config 2026-06-04 17:27:56 +02:00
Zoltán Papp
ca6f6d88cb Merge branch 'main' into ui-refactor 2026-06-04 17:06:11 +02:00
Zoltán Papp
e298747203 [client/ui] Refresh profile list in tray + UI on CLI profile add/remove
The daemon emits no dedicated profile-changed RPC event, and a profile
add/remove doesn't move the connection status, so the UI's SubscribeStatus
path never fired for CLI-driven `netbird profile add|remove` (and the tray's
iconChanged guard would swallow it anyway). The tray menu and the React
profile list stayed stale until the next status-string transition.

AddProfile/RemoveProfile now publish a marked INFO/SYSTEM event over
SubscribeEvents (metadata kind=profile-list-changed, empty userMessage so it
stays silent). The UI's dispatchSystemEvent recognises the marker and
re-emits the existing EventProfileChanged, which the tray's loadProfiles and
React's ProfileContext.refresh already subscribe to — so both surfaces
refresh from a single signal that originates in the shared daemon handler
(covering both CLI and UI-initiated removals). No proto change.

Also drop a stray, build-breaking `app.Updater` line in main.go.
2026-06-04 16:03:22 +02:00
Theodor Midtlien
5993ec6e43 [client] Allow wireguard port to be zero in UI and show port in status command (#6158)
* Allow wireguard port to be set to 0 in UI

* Add wireguard port to cmd status

* Correct protoc version
2026-06-04 15:04:11 +02:00
Zoltán Papp
cba4a8a63b [client/ui] Fix login wedging until restart after a failed SSO attempt
startLogin held both guards (the module-level loginInFlight and the
caller's React-level loginGuard) across the post-failure errorDialog
await. The native Windows MessageBox disables its parent for its whole
lifetime while the main window's WindowClosing hook hides instead of
closing, so the dialog promise can outlive the click — and even a clean
dismissal kept the guards held until the promise settled. Until then
every later Connect click and tray trigger-login was silently dropped at
the guard check, so the only way back was a client restart.

Release both guards the instant the flow itself settles, before the
dialog: startLogin now takes an onSettled callback fired in its finally
(driveLogin releases loginGuard through it), and the errorDialog await
moved out of the try/finally so no guard is ever gated on the dialog.
2026-06-04 13:42:29 +02:00
Zoltán Papp
93e068f753 [client/ui] Honour DEV=true in windows:build:console for DevTools
windows:build:console fixed -tags production, which disables the
WebKit/WebView2 DevTools inspector — so there was no way to get a
console-attached Windows binary with the frontend JS console reachable.
Mirror build:native's DEV handling: DEV=true drops the production tag
while keeping the console subsystem (no -H windowsgui).
2026-06-04 13:21:41 +02:00
Zoltán Papp
94065a8058 [client/ui] Enable tray status row on Linux to avoid greyed-out indicator
The informational status row at the top of the tray menu was disabled on
Linux, which painted the connection-status indicator greyed-out. Enable
it on Linux so the row renders at full opacity; it has no OnClick handler
so clicking it remains a no-op.
2026-06-04 11:42:22 +02:00
Maycon Santos
eac6d501c3 [infrastructure] allow docker image overrides for getting started (#6335)
* [infrastructure] allow docker image overrides for getting started

Make dashboard and server image configurations overrideable via environment variables

* [infrastructure] update Traefik gRPC rule to include ProxyService PathPrefix

* make Traefik and CrowdSec images configurable via environment variables
2026-06-04 11:24:47 +02:00
Maycon Santos
deeae30612 [misc] Add Codecov integration and coverage reporting across workflows (#6333) 2026-06-03 19:08:45 +02:00
Bethuel Mmbaga
f3cdf163e1 [management] Export ResolveDomain (#6334) 2026-06-03 19:53:57 +03:00
Eduard Gert
d7263a6be9 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor 2026-06-03 17:33:20 +02:00
Eduard Gert
64199209cf add onboarding 2026-06-03 17:33:13 +02:00
Zoltán Papp
166c6118e2 [client] Fix Up failing with NeedsLogin after SSO login
After a successful WaitSSOLogin the daemon deliberately stays in
StatusNeedsLogin, and after a mid-session expiry (peer kicked out by the
management server) the engine tears down with clientRunning == false. In
both cases the caller's Up takes the fresh-start branch, which only
accepted StatusIdle and rejected NeedsLogin with
"up already in progress: current status NeedsLogin".

This forced a second Up to actually connect (CLI: re-run `netbird up`;
GUI: click Connect again). Treat NeedsLogin as a legitimate fresh-start
entry state and reset it to Idle before starting the engine, so the
first Up after login drives Connecting -> Connected directly.
2026-06-03 17:19:52 +02:00
Zoltan Papp
3e61ccb162 [client] Persist sync response via pluggable store (disk on iOS) (#6331)
* Persist sync response via pluggable store (disk on iOS)

The latest Management sync response (which carries the network map) was
kept in memory for debug bundle generation. On memory-constrained
platforms like iOS the network map can be large enough to matter.

Introduce a syncstore package with a Store interface and two backends:
a memory backend (the previous behavior) and a disk backend that
serializes the response to a file in the state directory. The backend
is selected per-platform at build time: disk on iOS, memory elsewhere.

The disk store clears any leftover file on construction so a fresh
store never reads stale data from an earlier run (e.g. another
profile's network map).

In the engine, drop the separate persistSyncResponse bool: the store is
only instantiated while persistence is enabled, and its presence is
what marks persistence as active. The store is also cleared on engine
close so the file does not linger on disk.

* syncstore: silence nilnil linter on "nothing stored" returns

Get returns (nil, nil) to signal that nothing is stored, which is part
of the Store contract and preserves the original behaviour. Annotate
both backends with //nolint:nilnil so golangci-lint does not flag it.

* syncstore: hold syncRespMux for the whole store Set/Get

Both handleSync and GetLatestSyncResponse snapshotted e.syncStore under
the read lock and then released it before calling Set/Get. That allowed
SetSyncResponsePersistence(false) or engine close to clear the store
mid-call. In particular a concurrent Clear()+nil followed by a late
Set could re-create the file that was just removed, defeating the
leak/lingering protection.

Hold syncRespMux for the duration of the store operation in both spots
so the store cannot be cleared while a Set/Get is in flight.

* syncstore: avoid StateDir "." when state path is empty

On mobile the state path may be empty (the engine tolerates a missing
state file). filepath.Dir("") returns ".", which would make a
disk-backed syncstore write into the working directory instead of
letting NewDiskStore fall back to os.TempDir().

Only set engineConfig.StateDir when path is non-empty.
2026-06-03 14:18:50 +02:00
Viktor Liu
a48c20d8d8 [client] Gate DNS forwarder on BlockInbound (#6257) 2026-06-03 11:33:29 +02:00
Riccardo Manfrin
2b57a7d43b [client, management, misc] expose VCS revision in dev build version output (#6263)
* Refactor to use a common checker for development version

* Adds commit sha to development version for cobra command only

Leave dashboard unaffected

* Adjust for "v0.31.1-dev" test case

which must be considered pre-release

* Drop synthetic "dev"/"0.50.0-dev" firewall feature-gate fixtures

These test cases encoded the loose strings.Contains(v, "dev")
semantics inherited from peerSupportedFirewallFeatures, but
NetbirdVersion() never produces those values — only the literal
"development" (and now "development-<sha>[-dirty]") ever flows
through the wire. The agent owns the semantics of an ephemeral
development build, so the tests should exercise the strings we
actually emit.

Replaced with development, development-<sha> and
development-<sha>-dirty cases that match the HasPrefix("development")
predicate introduced upstream.

* Remove unexistent tests on wire format

The sha / dirty flag are added only when the CLI asks the version.
Account versions is unaffacted and can only strictly match "development"

* Adds tests for IsDevelopmentVersion
2026-06-03 08:56:50 +02:00
Eduard Gert
1710868a09 add exit node switcher 2026-06-02 17:23:56 +02:00
Zoltán Papp
7798b7cf14 [client/ui] Disable WebKit compositing on Linux to avoid Intel/Mesa SIGSEGV
WebKitGTK's accelerated GL compositor crashes with a SIGSEGV inside
g_application_run on some Intel setups, hitting Mesa anv/i965 code paths
for DRM format modifiers that aren't implemented (FINISHME: YUV
colorspace / multi-planar formats). Disabling the DMA-BUF renderer alone
doesn't cover the GL compositor, so the crash survived that workaround.

Set WEBKIT_DISABLE_COMPOSITING_MODE=1 in init() (skipped if the user
already set it) to force CPU rendering, which is fine for a UI this
small and sidesteps the broken modifier path.
2026-06-02 15:10:45 +02:00
Zoltán Papp
179966b000 [client/ui] Detect KDE panel dark mode from kdeglobals Complementary colour
Split Linux panel-theme detection into two files and fix the KDE case
where the tray icon picked the wrong mono variant.

The freedesktop Settings portal's color-scheme reports the *global*
light/dark preference, but the KDE panel is painted from the
Complementary colour group, which can be dark even when the global
scheme is Light. The tray sits on the panel, so keying its black/white
mono icon off the portal value alone gave the wrong contrast on KDE.

Changes:
  - tray_theme_linux.go keeps the dark/light decision; on KDE it now
    reads the user's kdeglobals [Colors:Complementary] BackgroundNormal
    to determine the actual panel luminance, falling back to the portal
    color-scheme / GTK_THEME chain elsewhere.
  - tray_theme_watcher_linux.go (new) owns the live half: a private
    session-bus connection for the portal SettingChanged signal plus an
    fsnotify watch on kdeglobals, repainting the tray on a panel-theme
    flip.
  - tray_theme_linux_test.go (new) covers the kdeglobals Complementary
    parse against the KDE test-VM's real file layout.
2026-06-02 14:29:45 +02:00
Eduard Gert
48265a0143 add profile switch into settings, scroll to top after profile switch, add netbird icon to linux windows 2026-06-02 14:20:36 +02:00
Maycon Santos
fa1e241aea [management, client, proxy] Follow-up fixes for private reverse-proxy services (#6268)
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker

forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.

Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.

* fix(proxy): harden inbound listener resource + startup-ctx handling

Three correctness fixes on the per-account inbound path, with tests:

- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
  back an *io.PipeWriter backed by a pipe + scanner goroutine that the
  caller owns; the two writers per account (https + plain) were never
  closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
  is launched in a goroutine from AddPeer and was inheriting the
  caller's request-scoped ctx, so a cancelled request could abort the
  inbound bring-up or fail the management status notification. The
  tail is split into notifyClientReady so the contract is testable.

Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.

* feat(proxy): short-circuit peer-own-target loops with 421

When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.

Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.

The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.

* fix(management): private-service validation + tunnel-IP lookup semantics

- Require an explicit port for L4 cluster targets. validateL4Target
  exempted TargetTypeCluster from the port check, but buildPathMappings
  serializes every L4 target via net.JoinHostPort(host, port) — port=0
  shipped a ":0" upstream. Cluster targets use the same Host/Port
  fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
  every error to Internal. The proxy's ValidateTunnelPeer probes IPs
  that legitimately aren't in the roster; the miss is expected and now
  distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
  request doesn't keep the store busy.

Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.

* fix(client): include offlinePeers in PeerStateByIP lookup

ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:

- causes the DNS filter to keep returning records pointing at peers
  that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
  peer rather than letting the cached management RPC carry the
  authorisation decision.

Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.

* fix(rest): reject empty Delete path params in reverse-proxy clients

ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).

Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.

* chore(api,ci,docs,test): private-service schema, proto-check, fixups

Non-functional cleanups and contract/CI hardening around the
private-service work:

API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
  on both Service and ServiceRequest, mirroring
  validatePrivateRequirements. mode stays optional-but-constrained
  (empty defaults to http server-side), matching runtime.

CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
  -gen-go-grpc suffix) so grpc-generated files are in scope.

Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
  (initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
  comments and the proto doc.

Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
  signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
  unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.

* fix(proxy): background ctx for already-started AddPeer notification

The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.

Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.

* use the cmd context for roundtripper
2026-06-02 13:40:09 +02:00
Zoltán Papp
9a76507b14 [client/ui] Fix Linux package metadata and KDE Wayland icon association
Two Linux packaging issues, both surfaced by the netbird-ui deb/rpm
built from .goreleaser_ui.yaml.

1) License / vendor metadata
----------------------------
The nfpm entries for the UI package set only maintainer/description/
homepage, leaving the License and Vendor RPM/DEB tags empty. KDE
Discover (and GNOME Software) then render the package as
"Licenses: Unknown" / "Unknown author", with a scary license-warning
popup on install. The daemon's main .goreleaser.yaml already set
license (commit #5659) but never vendor, and the UI config was skipped
entirely.

Fix: add `license: BSD-3-Clause` + `vendor: NetBird` to both UI nfpm
entries (deb + rpm), and `vendor: NetBird` to the daemon's deb + rpm
entries for consistency. BSD-3-Clause is correct for client/ui — the
repo is BSD-3-Clause except management/, signal/, relay/, combined/
(AGPLv3), none of which the UI touches.

2) KDE Wayland window/taskbar icon
----------------------------------
On KDE Plasma 6 under Wayland the app launched with the generic Wails
icon in the window titlebar and the taskbar / Alt-Tab switcher, even
though /usr/share/pixmaps/netbird.png (the launcher icon, resolved from
the desktop entry's `Icon=netbird`) was correct.

Root cause is how a Wayland compositor decides a window's icon. Unlike
X11 there is no per-window _NET_WM_ICON the app can push at runtime —
GTK4 even removed gtk_window_set_icon, so the embedded assets/netbird.png
the binary carries is simply ignored. Instead the compositor matches the
window's Wayland **app_id** to an installed .desktop file and uses that
entry's `Icon=` key.

The app_id is not "netbird": Wails hardcodes it as `org.wails.<name>`
(pkg/application/linux_cgo.go: `fmt.Sprintf("org.wails.%s", name)`, name
= sanitized Options.Name), yielding **org.wails.netbird**. There is no
Wails option to override the prefix. Verified the live value on the
Fedora-40 / KDE 6.3 / Wayland test VM by dumping workspace.windowList()
via a KWin script:

    js: ZZZWIN org.wails.netbird ## NetBird

KDE needs two things to associate the running window with our desktop
entry and thus paint our icon:
  - the desktop entry's basename should equal the app_id, so the
    titlebar decoration (which looks the entry up by app_id ->
    <app_id>.desktop) finds it, and
  - a `StartupWMClass=<app_id>` line, which the taskbar / task switcher
    use to map the surface to the entry.

Fix (no Wails fork needed — app_id stays org.wails.netbird, which the
user never sees; only Name=NetBird and the icon are visible):
  - install the desktop file as `org.wails.netbird.desktop` instead of
    `netbird.desktop` (both deb and rpm contents in .goreleaser_ui.yaml)
  - add `StartupWMClass=org.wails.netbird` to
    client/ui/build/linux/netbird.desktop

`Icon=netbird` and the pixmaps/netbird.png payload are unchanged — they
were already correct. Confirmed on the test VM that both the titlebar
and taskbar/Alt-Tab now show the NetBird icon.
2026-06-02 12:52:24 +02:00
Eduard Gert
c3a0c1beeb update language switcher, remove flags 2026-06-02 12:36:29 +02:00
Eduard Gert
45095818bc update paddings and icons 2026-06-02 12:04:55 +02:00
braginini
1f74ee9c78 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor 2026-06-02 11:45:39 +02:00
braginini
562a538e91 fix(tray): round session-remaining to nearest hour/day 2026-06-02 11:45:29 +02:00
Eduard Gert
50b26a21fd Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor 2026-06-02 11:38:03 +02:00
Eduard Gert
fefd0da7bf Adjust peers and resources tabs 2026-06-02 11:37:56 +02:00
braginini
d1f3d88f0d Rephrase session expiration 2026-06-02 11:37:48 +02:00
Zoltán Papp
a71ef1bab0 [client/ui] Guard session expiry updates to log only on change 2026-06-02 11:09:20 +02:00
Eduard Gert
16743c4ce5 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
# Conflicts:
#	client/ui/services/version.go
2026-06-02 09:37:57 +02:00
Eduard Gert
3c0a9c314b add gui version 2026-06-02 09:37:21 +02:00
Viktor Liu
e7c9182ff9 [client] Offer injected ICMPv6 echo replies to packet capture (#6321) 2026-06-01 19:38:00 +02:00
156 changed files with 4875 additions and 1515 deletions

18
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,18 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: en-US
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: true
poem: false
review_status: true
auto_review:
enabled: true
drafts: false
path_filters:
- "!**/*.tsx"
- "!**/*.ts"
- "!**/*.js"
- "!**/*.svg"
chat:
auto_reply: true

View File

@@ -53,5 +53,11 @@ jobs:
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
# root, which has no Go files.
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,client

View File

@@ -166,7 +166,15 @@ jobs:
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
# root, which has no Go files.
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,client
test_client_on_docker:
name: "Client (Docker) / Unit"
@@ -284,9 +292,17 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test ${{ matrix.raceFlag }} \
-exec 'sudo' \
-exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m -p 1 ./relay/... ./shared/relay/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,relay
test_proxy:
name: "Proxy / Unit"
needs: [build-cache]
@@ -334,7 +350,15 @@ jobs:
- name: Test
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test -timeout 10m -p 1 ./proxy/...
go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,proxy
test_signal:
name: "Signal / Unit"
@@ -385,9 +409,17 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test \
-exec 'sudo' \
-exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m ./signal/... ./shared/signal/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,signal
test_management:
name: "Management / Unit"
needs: [build-cache]
@@ -453,10 +485,18 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=devcert \
go test -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,management
benchmark:
name: "Management / Benchmark"
needs: [build-cache]
@@ -695,6 +735,14 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=integration \
go test -tags=integration -coverprofile=coverage.txt \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: integration,management

View File

@@ -20,15 +20,30 @@ jobs:
per_page: 100,
});
const modifiedPbFiles = files.filter(
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
);
if (modifiedPbFiles.length === 0) {
console.log('No modified .pb.go files to check');
// Cover renamed .pb.go files in addition to plain edits.
// Renamed entries land under the new path with previous_filename
// pointing at the base-side name, so we read the base content
// from the old path when present.
const changedPbFiles = files
.filter(f => (f.status === 'modified' || f.status === 'renamed')
&& f.filename.endsWith('.pb.go'))
.map(f => ({
headPath: f.filename,
basePath: f.previous_filename || f.filename,
}));
if (changedPbFiles.length === 0) {
console.log('No modified or renamed .pb.go files to check');
return;
}
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
// Matches the generator version headers protoc writes at the top
// of generated files:
// // protoc v3.21.12
// // protoc-gen-go v1.26.0
// // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
// The optional "- " prefix and the optional -gen-go / -gen-go-grpc
// suffixes keep the *_grpc.pb.go headers in scope.
const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
const baseSha = context.payload.pull_request.base.sha;
const headSha = context.payload.pull_request.head.sha;
@@ -55,20 +70,22 @@ jobs:
}
const violations = [];
for (const file of modifiedPbFiles) {
for (const file of changedPbFiles) {
const [base, head] = await Promise.all([
getVersionHeader(file.filename, baseSha),
getVersionHeader(file.filename, headSha),
getVersionHeader(file.basePath, baseSha),
getVersionHeader(file.headPath, headSha),
]);
if (!base.ok || !head.ok) {
core.warning(
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
`Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
);
continue;
}
if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
file: file.filename,
file: file.basePath === file.headPath
? file.headPath
: `${file.basePath} → ${file.headPath}`,
base: base.lines,
head: head.lines,
});

View File

@@ -196,6 +196,7 @@ nfpms:
description: Netbird client.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_deb
bindir: /usr/bin
builds:
@@ -210,6 +211,7 @@ nfpms:
description: Netbird client.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_rpm
bindir: /usr/bin
builds:

View File

@@ -70,6 +70,8 @@ nfpms:
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_deb
package_name: netbird-ui
builds:
@@ -80,7 +82,7 @@ nfpms:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
@@ -92,6 +94,8 @@ nfpms:
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm
package_name: netbird-ui
builds:
@@ -102,7 +106,7 @@ nfpms:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:

View File

@@ -19,6 +19,7 @@ import (
"github.com/netbirdio/netbird/client/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
"github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
@@ -100,6 +101,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -298,6 +300,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -432,6 +435,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
SyncResponse: syncResponse,
LogPath: logFilePath,
CPUProfile: nil,
DaemonVersion: version.NetbirdVersion(), // acting as daemon
},
debug.BundleConfig{
IncludeSystemInfo: true,

View File

@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
}
// Common setup for service control commands
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
// rootCmd env vars are already applied by PersistentPreRunE.
SetFlagsFromEnvVars(serviceCmd)
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
if err := util.InitLog(logLevel, logFiles...); err != nil {
return nil, fmt.Errorf("init log: %w", err)
if consoleLog {
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
return nil, fmt.Errorf("init log: %w", err)
}
} else {
if err := util.InitLog(logLevel, logFiles...); err != nil {
return nil, fmt.Errorf("init log: %w", err)
}
}
cfg, err := newSVCConfig()
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
s, err := setupServiceControlCommand(cmd, ctx, cancel)
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
Short: "starts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel)
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
Short: "stops NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel)
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
Short: "restarts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel)
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
Short: "shows NetBird service status",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel)
s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
if err != nil {
return err
}

View File

@@ -12,7 +12,13 @@ var (
Short: "Print the NetBird's client application version",
Run: func(cmd *cobra.Command, args []string) {
cmd.SetOut(cmd.OutOrStdout())
cmd.Println(version.NetbirdVersion())
out := version.NetbirdVersion()
if version.IsDevelopmentVersion(out) {
if commit := version.NetbirdCommit(); commit != "" {
out += "-" + commit
}
}
cmd.Println(out)
},
}
)

View File

@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
return 0
}
if pc := f.endpoint.capture.Load(); pc != nil {
(*pc).Offer(fullPacket, true)
}
return len(fullPacket)
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -355,6 +356,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
// Leave StateDir empty when there is no state path so a disk-backed
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
if path != "" {
engineConfig.StateDir = filepath.Dir(path)
}
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)

View File

@@ -254,6 +254,8 @@ type BundleGenerator struct {
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter
daemonVersion string
cliVersion string
anonymize bool
includeSystemInfo bool
@@ -278,6 +280,8 @@ type GeneratorDependencies struct {
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -299,6 +303,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics,
daemonVersion: deps.DaemonVersion,
cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
includeSystemInfo: cfg.IncludeSystemInfo,
@@ -459,9 +465,11 @@ func (g *BundleGenerator) addStatus() error {
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize,
ProfileName: profName,
Anonymize: g.anonymize,
ProfileName: profName,
DaemonVersion: g.daemonVersion,
})
overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
statusReader := strings.NewReader(statusOutput)
@@ -1039,7 +1047,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
return
}
pattern := filepath.Join(logDir, "client-*.log.gz")
// This regex will match both logs rotated by us and logrotate on linux
pattern := filepath.Join(logDir, "client*.log.*")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)
@@ -1072,7 +1081,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
if err := g.addSingleLogFileGz(files[i], name); err != nil {
if strings.HasSuffix(name, ".gz") {
err = g.addSingleLogFileGz(files[i], name)
} else {
err = g.addSingleLogfile(files[i], name)
}
if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
}
}

View File

@@ -0,0 +1,103 @@
package debug
import (
"archive/zip"
"bytes"
"compress/gzip"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
// and gzipped), and skips unrelated files.
func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
logrotatePlain := "client.log.1"
writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
logrotateGz := "client.log.2.gz"
writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
names := runAddRotatedLogFiles(t, dir, 10)
require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
}
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
// logFileCount rotated files are bundled, ordered by mtime.
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
dir := t.TempDir()
oldest := filepath.Join(dir, "client.log.3")
middle := filepath.Join(dir, "client.log.2")
newest := filepath.Join(dir, "client.log.1")
writeFile(t, oldest, "old\n")
writeFile(t, middle, "mid\n")
writeFile(t, newest, "new\n")
now := time.Now()
require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
require.NoError(t, os.Chtimes(newest, now, now))
names := runAddRotatedLogFiles(t, dir, 2)
require.Contains(t, names, "client.log.1")
require.Contains(t, names, "client.log.2")
require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
}
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
// zip writer and returns the set of entry names that ended up in the archive.
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
t.Helper()
var buf bytes.Buffer
g := &BundleGenerator{
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
g.addRotatedLogFiles(dir)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
require.NoError(t, err)
names := make(map[string]struct{}, len(zr.File))
for _, f := range zr.File {
names[f.Name] = struct{}{}
}
return names
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
func writeGzFile(t *testing.T, path, content string) {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
_, err := io.WriteString(gw, content)
require.NoError(t, err)
require.NoError(t, gw.Close())
require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
}

View File

@@ -22,7 +22,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/protobuf/proto"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
@@ -56,6 +55,7 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
@@ -72,6 +72,7 @@ import (
sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
"github.com/netbirdio/netbird/version"
)
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -148,6 +149,10 @@ type EngineConfig struct {
LogPath string
TempDir string
// StateDir is the directory holding the state file. The sync response
// (network map) is serialized here on platforms that persist it to disk.
StateDir string
}
// EngineServices holds the external service dependencies required by the Engine.
@@ -226,10 +231,15 @@ type Engine struct {
afpacketCapture *capture.AFPacketCapture
// Sync response persistence (protected by syncRespMux)
syncRespMux sync.RWMutex
persistSyncResponse bool
latestSyncResponse *mgmProto.SyncResponse
// Sync response persistence (protected by syncRespMux).
// syncStore is nil unless persistence has been enabled; its presence is
// what marks persistence as active. The backend (disk or memory) is
// selected per-platform; see the syncstore package. syncStoreDir is where
// a disk-backed store serializes to.
syncRespMux sync.RWMutex
syncStore syncstore.Store
syncStoreDir string
flowManager nftypes.FlowManager
// auto-update
@@ -306,6 +316,7 @@ func NewEngine(
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
updateManager: services.UpdateManager,
syncStoreDir: config.StateDir,
}
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
// session expiry deadline. Deadline-change ticks come for free via
@@ -943,26 +954,27 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
// Read the storage-enabled flag under the syncRespMux too.
e.syncRespMux.RLock()
enabled := e.persistSyncResponse
e.syncRespMux.RUnlock()
// Store sync response if persistence is enabled
if enabled {
e.syncRespMux.Lock()
e.latestSyncResponse = update
e.syncRespMux.Unlock()
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
}
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
return err
}
// Persist sync response only after updateNetworkMap accepted and applied the update,
// so GetLatestSyncResponse() never returns state the engine did not actually apply.
// Done under the dedicated lock (syncRespMux), not under syncMsgMux.
// A non-nil syncStore is what marks persistence as enabled. Hold the lock for
// the whole Set so the store cannot be cleared (disabled / engine close)
// mid-call and have this write resurrect a file that was just removed.
e.syncRespMux.RLock()
if e.syncStore != nil {
if err := e.syncStore.Set(update); err != nil {
log.Errorf("failed to persist sync response: %v", err)
} else {
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
}
}
e.syncRespMux.RUnlock()
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
return nil
@@ -1094,6 +1106,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
state.FQDN = conf.GetFqdn()
state.WgPort = e.config.WgPort
e.statusRecorder.UpdateLocalPeerState(state)
@@ -1172,6 +1185,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
LogPath: e.config.LogPath,
TempDir: e.config.TempDir,
ClientMetrics: e.clientMetrics,
DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
e.RunHealthProbes(e.ctx, true)
},
@@ -1844,6 +1858,18 @@ func (e *Engine) close() {
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
}
// Drop any persisted sync response so its network map does not linger on
// disk after the engine stops (and cannot leak into a later run).
e.syncRespMux.Lock()
store := e.syncStore
e.syncStore = nil
e.syncRespMux.Unlock()
if store != nil {
if err := store.Clear(); err != nil {
log.Warnf("failed to clear persisted sync response on close: %v", err)
}
}
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
@@ -2186,45 +2212,42 @@ func (e *Engine) stopDNSServer() {
e.statusRecorder.UpdateDNSStates(nsGroupStates)
}
// SetSyncResponsePersistence enables or disables sync response persistence
// SetSyncResponsePersistence enables or disables sync response persistence.
// The store is only instantiated while persistence is enabled; construction
// itself drops any stale data left over from an earlier run (see syncstore).
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
e.syncRespMux.Lock()
defer e.syncRespMux.Unlock()
if enabled == e.persistSyncResponse {
if enabled == (e.syncStore != nil) {
return
}
e.persistSyncResponse = enabled
log.Debugf("Sync response persistence is set to %t", enabled)
if !enabled {
e.latestSyncResponse = nil
if err := e.syncStore.Clear(); err != nil {
log.Warnf("failed to clear persisted sync response: %v", err)
}
e.syncStore = nil
return
}
e.syncStore = syncstore.New(e.syncStoreDir)
}
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
// Hold the lock for the whole Get so the store cannot be cleared
// (disabled / engine close) mid-call.
e.syncRespMux.RLock()
enabled := e.persistSyncResponse
latest := e.latestSyncResponse
e.syncRespMux.RUnlock()
defer e.syncRespMux.RUnlock()
if !enabled {
if e.syncStore == nil {
return nil, errors.New("sync response persistence is disabled")
}
if latest == nil {
//nolint:nilnil
return nil, nil
}
log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
if !ok {
return nil, fmt.Errorf("failed to clone sync response")
}
return sr, nil
//nolint:nilnil
return e.syncStore.Get()
}
// GetWgAddr returns the wireguard address
@@ -2260,7 +2283,7 @@ func (e *Engine) updateDNSForwarder(
enabled bool,
fwdEntries []*dnsfwd.ForwarderEntry,
) {
if e.config.DisableServerRoutes {
if e.config.DisableServerRoutes || e.config.BlockInbound {
return
}

View File

@@ -4,6 +4,8 @@ import (
"strings"
"github.com/hashicorp/go-version"
nbversion "github.com/netbirdio/netbird/version"
)
var (
@@ -11,7 +13,7 @@ var (
)
func IsSupported(agentVersion string) bool {
if agentVersion == "development" {
if nbversion.IsDevelopmentVersion(agentVersion) {
return true
}

View File

@@ -113,6 +113,7 @@ type LocalPeerState struct {
PubKey string
KernelInterface bool
FQDN string
WgPort int
Routes map[string]struct{}
}
@@ -334,8 +335,12 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
// PeerStateByIP returns the full peer State for the given tunnel IP.
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
// address so dual-stack peers are reachable on either family. Returns the
// zero State and false when no peer matches or the input is empty.
// address so dual-stack peers are reachable on either family. Searches
// both d.peers and d.offlinePeers — peers that have been moved into
// the offline slice by ReplaceOfflinePeers are still part of the
// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
// need to recognise them rather than treating them as unknown. Returns
// the zero State and false when no peer matches or the input is empty.
func (d *Status) PeerStateByIP(ip string) (State, bool) {
if ip == "" {
return State{}, false
@@ -348,6 +353,11 @@ func (d *Status) PeerStateByIP(ip string) (State, bool) {
return state, true
}
}
for _, state := range d.offlinePeers {
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
return state, true
}
}
return State{}, false
}
@@ -1518,6 +1528,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)

View File

@@ -90,6 +90,28 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
}
// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
// been moved into the offline slice via ReplaceOfflinePeers. Callers
// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
// rather than unknown — otherwise authentication / DNS filtering treats
// known-but-offline peers as foreign IPs.
func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
status := NewRecorder("https://mgm")
req := require.New(t)
status.ReplaceOfflinePeers([]State{
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
})
state, ok := status.PeerStateByIP("100.64.0.20")
req.True(ok, "offline peer must resolve by IPv4 tunnel address")
req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
state, ok = status.PeerStateByIP("fd00::20")
req.True(ok, "offline peer must resolve by IPv6 tunnel address")
req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
}
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/netbirdio/netbird/util"
@@ -59,3 +60,22 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return nil
}
// RemoveProfileState deletes the per-profile state file (which holds the
// account email used for the SSO login hint and the UI display). Called after
// a successful logout so a logged-out profile no longer shows a stale account
// email. The state file only stores the email, so deleting it is equivalent to
// clearing it; the next SSO login recreates it. A missing file is not an error.
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
stateFile := filepath.Join(configDir, profileName+".state.json")
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove profile state: %w", err)
}
return nil
}

View File

@@ -0,0 +1,99 @@
package syncstore
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/util"
)
// syncResponseFileName is the name of the file the sync response is serialized
// to, placed inside the configured directory (the state directory).
const syncResponseFileName = "networkmap.pb"
// diskStore serializes the latest sync response to a file on disk instead of
// keeping it in memory. This trades disk I/O for a much smaller memory
// footprint, which matters on memory-constrained platforms (iOS).
type diskStore struct {
mu sync.Mutex
path string
}
// NewDiskStore returns a Store that serializes the sync response to a file in
// the given directory. If dir is empty it falls back to the OS temp directory.
//
// Any file left over from a previous run is removed on construction so a fresh
// store never reads stale data (e.g. another profile's network map).
func NewDiskStore(dir string) Store {
if dir == "" {
dir = os.TempDir()
}
s := &diskStore{
path: filepath.Join(dir, syncResponseFileName),
}
if err := s.Clear(); err != nil {
log.Warnf("failed to clear stale sync response file: %v", err)
}
return s
}
func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
if resp == nil {
return s.Clear()
}
bs, err := proto.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal sync response: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
return fmt.Errorf("write sync response to %s: %w", s.path, err)
}
log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
return nil
}
func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
bs, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
return nil, nil
}
return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
}
resp := &mgmProto.SyncResponse{}
if err := proto.Unmarshal(bs, resp); err != nil {
return nil, fmt.Errorf("unmarshal sync response: %w", err)
}
log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
return resp, nil
}
func (s *diskStore) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove sync response file %s: %w", s.path, err)
}
return nil
}

View File

@@ -0,0 +1,9 @@
//go:build ios
package syncstore
// New returns the platform default store. On iOS the sync response is
// serialized to disk (in dir) to keep it out of the constrained process memory.
func New(dir string) Store {
return NewDiskStore(dir)
}

View File

@@ -0,0 +1,9 @@
//go:build !ios
package syncstore
// New returns the platform default store. On all non-iOS platforms the sync
// response is kept in memory; dir is unused.
func New(_ string) Store {
return NewMemoryStore()
}

View File

@@ -0,0 +1,56 @@
package syncstore
import (
"fmt"
"sync"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// memoryStore keeps the latest sync response in memory.
type memoryStore struct {
mu sync.RWMutex
latest *mgmProto.SyncResponse
}
// NewMemoryStore returns a Store that keeps the sync response in memory.
func NewMemoryStore() Store {
return &memoryStore{}
}
func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
s.latest = resp
return nil
}
func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
s.mu.RLock()
latest := s.latest
s.mu.RUnlock()
if latest == nil {
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
return nil, nil
}
log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest))
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
if !ok {
return nil, fmt.Errorf("clone sync response")
}
return sr, nil
}
func (s *memoryStore) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
s.latest = nil
return nil
}

View File

@@ -0,0 +1,29 @@
// Package syncstore stores the latest Management sync response (which carries
// the network map) for debug bundle generation.
//
// The storage backend is selected at build time per operating system: on iOS
// the response is serialized to disk to keep it out of the (tightly
// constrained) process memory, while on all other platforms it is kept in
// memory. The backend is chosen by the New constructor; see factory_ios.go and
// factory_other.go.
package syncstore
import (
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// Store persists the latest sync response and returns it on demand.
//
// Implementations must be safe for concurrent use.
type Store interface {
// Set stores the given sync response, replacing any previously stored one.
Set(resp *mgmProto.SyncResponse) error
// Get returns the stored sync response, or nil if none is stored.
// The returned value is an independent copy that the caller may retain.
Get() (*mgmProto.SyncResponse, error)
// Clear removes any stored sync response. It is safe to call when nothing
// is stored.
Clear() error
}

View File

@@ -19,8 +19,6 @@ import (
const (
latestVersion = "latest"
// this version will be ignored
developmentVersion = "development"
)
var errNoUpdateState = errors.New("no update state found")
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
}
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
if m.currentVersion == developmentVersion {
if version.IsDevelopmentVersion(m.currentVersion) {
log.Debugf("skipping auto-update, running development version")
return false
}

View File

@@ -1638,6 +1638,7 @@ type LocalPeerState struct {
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1728,6 +1729,13 @@ func (x *LocalPeerState) GetIpv6() string {
return ""
}
func (x *LocalPeerState) GetWgPort() int32 {
if x != nil {
return x.WgPort
}
return 0
}
// SignalState contains the latest state of a signal connection
type SignalState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2745,6 +2753,7 @@ type DebugBundleRequest struct {
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2807,6 +2816,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
return 0
}
func (x *DebugBundleRequest) GetCliVersion() string {
if x != nil {
return x.CliVersion
}
return ""
}
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -6739,7 +6755,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"sshHostKey\x18\x13 \x01(\fR\n" +
"sshHostKey\x12\x12\n" +
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
"\x0eLocalPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
@@ -6748,7 +6764,8 @@ const file_daemon_proto_rawDesc = "" +
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
"\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
"\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
"\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
"\vSignalState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
@@ -6826,14 +6843,17 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
"\n" +
"cliVersion\x18\x06 \x01(\tR\n" +
"cliVersion\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +

View File

@@ -384,6 +384,7 @@ message LocalPeerState {
bool rosenpassPermissive = 6;
repeated string networks = 7;
string ipv6 = 8;
int32 wgPort = 9;
}
// SignalState contains the latest state of a signal connection
@@ -512,6 +513,7 @@ message DebugBundleRequest {
bool systemInfo = 3;
string uploadURL = 4;
uint32 logFileCount = 5;
string cliVersion = 6;
}
message DebugBundleResponse {

View File

@@ -1,17 +1,16 @@
#!/bin/bash
set -e
if ! which realpath > /dev/null 2>&1
then
echo realpath is not installed
echo run: brew install coreutils
exit 1
if ! which realpath >/dev/null 2>&1; then
echo realpath is not installed
echo run: brew install coreutils
exit 1
fi
old_pwd=$(pwd)
script_path=$(dirname $(realpath "$0"))
script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
cd "$old_pwd"

View File

@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
@@ -70,6 +71,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
CapturePath: capturePath,
RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics,
DaemonVersion: version.NetbirdVersion(),
CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),

View File

@@ -793,6 +793,22 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return nil, err
}
// StatusNeedsLogin is a legitimate fresh-start entry state: a successful
// WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is
// done, the token is in hand, but the engine hasn't been brought up yet —
// see WaitSSOLogin's state-transition table). The same holds after a
// mid-session expiry tore the engine down (clientRunning == false) and the
// user re-authenticated. In both cases the caller's Up is expected to drive
// the connection; treat NeedsLogin like Idle and reset to Idle so the
// engine's own StatusConnecting → StatusConnected progression starts from a
// clean slate. Without this, the first Up after an SSO login fails with
// "up already in progress" and the user has to trigger Up a second time
// (CLI: re-run `netbird up`; GUI: click Connect again).
if status == internal.StatusNeedsLogin {
status = internal.StatusIdle
state.Set(internal.StatusIdle)
}
if status != internal.StatusIdle {
s.mutex.Unlock()
return nil, fmt.Errorf("up already in progress: current status %s", status)
@@ -1194,7 +1210,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
}
}()
return mgmClient.Logout()
if err := mgmClient.Logout(); err != nil {
// The peer is already gone from the management server (e.g. deleted
// from the dashboard). The logout's goal — deregistering this peer —
// is therefore already satisfied, so treat NotFound as success rather
// than blocking the logout/profile-removal flow.
if logoutPeerGone(err) {
log.Infof("peer already removed from management server, treating logout as successful")
return nil
}
return err
}
return nil
}
// Status returns the daemon status
@@ -1848,6 +1876,8 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, fmt.Errorf("failed to create profile: %w", err)
}
s.publishProfileListChanged(msg.ProfileName)
return &proto.AddProfileResponse{}, nil
}
@@ -1869,9 +1899,32 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, fmt.Errorf("failed to remove profile: %w", err)
}
s.publishProfileListChanged(msg.ProfileName)
return &proto.RemoveProfileResponse{}, nil
}
// publishProfileListChanged nudges the desktop UI to refresh its profile list
// after a CLI-driven add/remove. The daemon exposes no dedicated
// profile-changed RPC event, and a profile add/remove doesn't move the
// connection status, so the UI's SubscribeStatus path never fires for it (and
// the tray's status-string guard would swallow it anyway). Instead we publish
// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
// recognises the metadata "kind" marker and translates it into its internal
// profile-changed signal that both the tray menu and the React profile views
// already subscribe to (see client/ui/services/daemon_feed.go,
// MetadataKindProfileListChanged). userMessage is intentionally empty so this
// stays a silent refresh signal rather than a user-facing notification.
func (s *Server) publishProfileListChanged(profileName string) {
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Profile list changed",
"",
map[string]string{"kind": "profile-list-changed", "profile": profileName},
)
}
// ListProfiles lists all profiles in the daemon.
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
s.mutex.Lock()
@@ -2076,3 +2129,15 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
}
return nil
}
// logoutPeerGone reports whether a management Logout failed because the peer
// no longer exists server-side (gRPC NotFound), walking the wrap chain since
// the client wraps the gRPC status with fmt.Errorf.
func logoutPeerGone(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound {
return true
}
}
return false
}

View File

@@ -147,6 +147,7 @@ type OutputOverview struct {
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
PubKey string `json:"publicKey" yaml:"publicKey"`
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
FQDN string `json:"fqdn" yaml:"fqdn"`
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
@@ -196,6 +197,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
@@ -569,6 +571,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
daemonVersion := "N/A"
if o.DaemonVersion != "" {
daemonVersion = o.DaemonVersion
}
cliVersion := version.NetbirdVersion()
if o.CliVersion != "" {
cliVersion = o.CliVersion
}
wgPortString := "N/A"
if o.WgPort > 0 {
wgPortString = fmt.Sprintf("%d", o.WgPort)
}
summary := fmt.Sprintf(
"OS: %s\n"+
"Daemon version: %s\n"+
@@ -582,6 +599,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"NetBird IP: %s\n"+
"%s"+
"Interface type: %s\n"+
"Wireguard port: %s\n"+
"Quantum resistance: %s\n"+
"Lazy connection: %s\n"+
"SSH Server: %s\n"+
@@ -590,8 +608,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
o.DaemonVersion,
version.NetbirdVersion(),
daemonVersion,
cliVersion,
o.ProfileName,
managementConnString,
signalConnString,
@@ -601,6 +619,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
interfaceIP,
ipv6Line,
interfaceTypeString,
wgPortString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,

View File

@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
Ipv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
WgPort: 51820,
Fqdn: "some-localhost.awesome-domain.com",
Networks: []string{
"10.10.0.0/24",
@@ -210,6 +211,7 @@ var overview = OutputOverview{
IPv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
WgPort: 51820,
FQDN: "some-localhost.awesome-domain.com",
NSServerGroups: []NsServerGroupStateOutput{
{
@@ -369,6 +371,7 @@ func TestParsingToJSON(t *testing.T) {
"netbirdIpv6": "fd00::100",
"publicKey": "Some-Pub-Key",
"usesKernelInterface": true,
"wireguardPort": 51820,
"fqdn": "some-localhost.awesome-domain.com",
"quantumResistance": false,
"quantumResistancePermissive": false,
@@ -487,6 +490,7 @@ netbirdIp: 192.168.178.100/16
netbirdIpv6: fd00::100
publicKey: Some-Pub-Key
usesKernelInterface: true
wireguardPort: 51820
fqdn: some-localhost.awesome-domain.com
quantumResistance: false
quantumResistancePermissive: false
@@ -579,12 +583,13 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
Wireguard port: %d
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
Networks: 10.10.0.0/24
Peers count: 2/2 Connected
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
assert.Equal(t, expectedDetail, detail)
}
@@ -604,6 +609,7 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
Wireguard port: 51820
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled

View File

@@ -9,7 +9,8 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
### Go (top-level package `main`)
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch``app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
- `tray.go``Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
- `tray_linux.go` `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` `fillExitNodeSubmenu`), daemon-version row (`tray_status.go`), and the About → Update row (`tray_update.go applyState``onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
- `tray_linux.go``init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked.
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`.
- `signal_unix.go` / `signal_windows.go``listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
@@ -36,9 +37,9 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. |
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
@@ -95,6 +96,7 @@ The main window is created up front in `main.go`. Auxiliary windows are created
- **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
- **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow.Currently no triggers wired — daemon-status integration is a follow-up.
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
The four lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
@@ -147,6 +149,8 @@ The tray uses Wails' built-in `notifications` service. One `notifications.Notifi
`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout".
Both `windows:build` and `windows:build:console` (the latter outputs `bin/netbird-ui-console.exe` linked against the console subsystem, so Go stdout/stderr/logrus print to the launching terminal) honour `DEV=true`, which drops the `-tags production` flag. The `production` tag is what disables the WebKit/WebView2 DevTools inspector — so `DEV=true` is the only way to get a Windows binary where the frontend JS console is reachable (right-click → Inspect / F12). Cross-compile from Linux with `CGO_ENABLED=1 task windows:build:console DEV=true`.
## Useful references
- `WAILS-DIALOGS.md` (sibling) — full `@wailsio/runtime` `Dialogs` API + per-OS behaviour + frameless-window pattern.
- `LINUX-TRAY.md` (sibling) — StatusNotifierWatcher + XEmbed host details.

View File

@@ -5,4 +5,5 @@ Icon=netbird
Type=Application
Terminal=false
Categories=Utility;
Keywords=netbird;
Keywords=netbird;
StartupWMClass=org.wails.netbird

View File

@@ -41,6 +41,11 @@ tasks:
Cross-compile from Linux works the same way:
CGO_ENABLED=1 task windows:build:console
Pass DEV=true to drop the `production` build tag so the WebKit/WebView2
DevTools inspector (right-click → Inspect, or F12) stays enabled and the
frontend JS console is reachable — same DEV handling as windows:build:
CGO_ENABLED=1 task windows:build:console DEV=true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
@@ -61,9 +66,11 @@ tasks:
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
# Identical to build:native's flags except no -H windowsgui, so the
# binary attaches to the launching console.
BUILD_FLAGS: '-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"'
# Identical to build:native's flags (including DEV handling) except no
# -H windowsgui, so the binary attaches to the launching console. With
# DEV=true the `production` tag is dropped, keeping the WebKit/WebView2
# DevTools inspector enabled so the frontend JS console is reachable.
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
env:

View File

@@ -27,12 +27,13 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). |
| `/dialog/session-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
In `app.tsx` the four dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. `DialogProvider` is outermost (and outside the daemon-availability gate) so `useConfirm()` works everywhere regardless of daemon state. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
Page-specific chrome lives next to the page, not in the layout:
- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only:
@@ -53,21 +54,22 @@ Page-specific chrome lives next to the page, not in the layout:
- `modules/login/``LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window).
- `modules/session/``SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
- `modules/auto-update/``UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
- `modules/profiles/``ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`.
- `modules/profiles/``ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal.
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
- `components/` — presentational primitives, no domain coupling. Grouped by family:
- `components/buttons/``Button`, `IconButton`.
- `components/inputs/``Input`, `SearchInput`.
- `components/dialog/``Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`.
- `components/dialog/``Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly).
- `components/switches/``SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
- `components/typography/``Label`, `HelpText`.
- `components/empty-state/``EmptyState`, `NoResults`, `NotConnectedState`.
- Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives).
- `layouts/``AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`.
- `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`).
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`.
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller.
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
## Wails event bus
@@ -180,7 +182,7 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
**Always go through `src/lib/dialogs.ts`**`errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime and re-enables it on dismissal; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged.
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). Confirmations `warningDialog` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). For **confirmations inside an app window** (the polished surfaces), prefer the in-app `useConfirm()` from `contexts/DialogContext.tsx` over the native `warningDialog``const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). Reserve the native `warningDialog` (compare against the **Label string**, not an index) for confirmations raised outside a normal app window (tray-driven flows, etc.). **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
## Tailwind tokens

View File

@@ -5,6 +5,7 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { MainPage } from "@/modules/main/MainPage.tsx";
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
@@ -39,6 +40,7 @@ Promise.all([
<Route path="install-progress" element={<UpdateInProgressDialog />} />
<Route path="session-expired" element={<SessionExpiredDialog />} />
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
<Route path="welcome" element={<WelcomeDialog />} />
</Route>
<Route element={<AppLayout />}>
<Route index element={<MainPage />} />

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 512 512">
<path fill="#ffce00" d="M0 341.3h512V512H0z"/>
<path fill="#000001" d="M0 0h512v170.7H0z"/>
<path fill="red" d="M0 170.7h512v170.6H0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 232 B

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 512 512">
<path fill="#012169" d="M0 0h512v512H0z"/>
<path fill="#FFF" d="M512 0v64L322 256l190 187v69h-67L254 324 68 512H0v-68l186-187L0 74V0h62l192 188L440 0z"/>
<path fill="#C8102E" d="m184 324 11 34L42 512H0v-3zm124-12 54 8 150 147v45zM512 0 320 196l-4-44L466 0zM0 1l193 189-59-8L0 49z"/>
<path fill="#FFF" d="M176 0v512h160V0zM0 176v160h512V176z"/>
<path fill="#C8102E" d="M0 208v96h512v-96zM208 0v512h96V0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 505 B

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-hu" viewBox="0 0 512 512">
<g fill-rule="evenodd">
<path fill="#fff" d="M512 512H0V0h512z"/>
<path fill="#388d00" d="M512 512H0V341.3h512z"/>
<path fill="#d43516" d="M512 170.8H0V.1h512z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -4,7 +4,7 @@ import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, Search } from "lucide-react";
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/typography/HelpText";
@@ -13,43 +13,17 @@ import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
// Flags live alongside the rest of the SVG flag library under
// assets/flags/1x1 and are filename-matched to the language code
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
// build time; the JS bundle only holds URL refs, not the SVG bytes.
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
eager: true,
import: "default",
query: "?url",
});
// Intentionally no flag icons here: flags represent countries, not
// languages (German is spoken across DE/AT/CH; English across US/UK/AU/
// etc.). Each label shows the endonym followed by the englishName in
// parentheses when the two differ (e.g. "Deutsch (German)"), in both
// the trigger and the dropdown rows.
// See: https://www.flagsarenotlanguages.com/blog/
const flagByCode: Record<string, string> = {};
for (const path in FLAG_URLS) {
const match = path.match(/1x1\/([^/]+)\.svg$/);
if (match) flagByCode[match[1]] = FLAG_URLS[path];
}
const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]];
function Flag({ code, label }: { code: string; label: string }) {
const src = flagFor(code);
if (!src) {
return (
<span
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
aria-hidden
/>
);
}
return (
<img
src={src}
alt={label}
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
draggable={false}
/>
);
}
const labelFor = (lang: Language): string =>
lang.englishName && lang.englishName !== lang.displayName
? `${lang.displayName} (${lang.englishName})`
: lang.displayName;
export function LanguagePicker() {
const { t, i18n } = useTranslation();
@@ -121,9 +95,9 @@ export function LanguagePicker() {
"disabled:opacity-50",
)}
>
{current && <Flag code={current.code} label={current.displayName} />}
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
<span className={"truncate flex-1 text-left"}>
{current?.displayName ?? "—"}
{current ? labelFor(current) : "—"}
</span>
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
</button>
@@ -193,12 +167,8 @@ export function LanguagePicker() {
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
)}
>
<Flag
code={lang.code}
label={lang.displayName}
/>
<span className={"flex-1 truncate"}>
{lang.displayName}
<span className={"flex-1 min-w-0 truncate"}>
{labelFor(lang)}
</span>
<span
className={

View File

@@ -7,21 +7,28 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
// fullWidth stretches the segmented control to fill its container —
// the SettingsGeneral row uses the default (shrink-to-content) layout,
// the welcome dialog asks for the wide variant so the picker spans the
// narrow dialog width.
fullWidth?: boolean;
};
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => {
const { t, i18n } = useTranslation();
const itemClass = fullWidth ? "flex-1" : undefined;
return (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as ManagementMode)}
className={fullWidth ? "w-full" : undefined}
>
<SwitchItem value={ManagementMode.Cloud}>
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
{t("settings.general.management.cloud")}
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted}>
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
{t("settings.general.management.selfHosted")}
</SwitchItem>
</SwitchItemGroup>

View File

@@ -3,18 +3,36 @@ import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (ConfirmDialog, etc.). Renders a bordered dark tile with the provided
// lucide icon centered inside.
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
// icon centered inside. The `tone` selects the semantic colour scheme —
// `default` keeps the neutral dark tile; info/warning/danger tint the tile,
// border and icon to match the action's severity.
export type SquareIconTone = "default" | "info" | "warning" | "danger";
const toneClass: Record<SquareIconTone, string> = {
default: "bg-nb-gray-920 border-nb-gray-900 text-white",
info: "bg-sky-950 border-sky-500 text-sky-100",
warning: "bg-netbird-950 border-netbird text-netbird",
danger: "bg-red-950 border-red-500 text-red-500",
};
type SquareIconProps = {
icon: ComponentType<LucideProps>;
iconSize?: number;
tone?: SquareIconTone;
className?: string;
};
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
export const SquareIcon = ({
icon: Icon,
iconSize = 20,
tone = "default",
className,
}: SquareIconProps) => (
<div
className={cn(
"h-11 w-11 rounded-lg flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
"h-11 w-11 rounded-lg flex items-center justify-center border",
toneClass[tone],
className,
)}
>

View File

@@ -1,4 +1,4 @@
import { ReactNode, useRef, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn";
@@ -9,8 +9,15 @@ type Props = {
align?: RTooltip.TooltipContentProps["align"];
delayDuration?: number;
sideOffset?: number;
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
// Overrides the default tooltip-content chrome (background, padding,
// border, radius). Use when a richer body needs popover-style layout.
contentClassName?: string;
// Ms to wait after pointer-leave before closing. Lets the user cross
// a gap between trigger and content without the tooltip snapping shut.
closeDelay?: number;
};
export const Tooltip = ({
@@ -20,14 +27,35 @@ export const Tooltip = ({
align = "center",
delayDuration = 200,
sideOffset = 6,
alignOffset = 0,
interactive = false,
keepOpenOnClick = true,
contentClassName,
closeDelay = 0,
}: Props) => {
const [open, setOpen] = useState(false);
const hoveringRef = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
if (closeDelay <= 0) {
setOpen(false);
return;
}
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
};
useEffect(() => () => cancelClose(), []);
const handleOpenChange = (next: boolean) => {
if (!next && keepOpenOnClick && hoveringRef.current) return;
if (next) cancelClose();
setOpen(next);
};
@@ -41,10 +69,11 @@ export const Tooltip = ({
asChild
onPointerEnter={() => {
hoveringRef.current = true;
cancelClose();
}}
onPointerLeave={() => {
hoveringRef.current = false;
setOpen(false);
scheduleClose();
}}
>
{children}
@@ -54,15 +83,19 @@ export const Tooltip = ({
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={
interactive ? undefined : (e) => e.preventDefault()
}
className={cn(
"z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
"text-xs text-nb-gray-100 shadow-lg",
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
!interactive && "pointer-events-none",
contentClassName ??
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
)}
>
{content}

View File

@@ -0,0 +1,42 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Tooltip } from "@/components/Tooltip";
type Props = {
text: string;
className?: string;
tooltipContent?: ReactNode;
delayDuration?: number;
};
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
// the "tooltip on hover even though everything fits" annoyance. The caller
// supplies the wrapper styling (font, max-width, etc.) via className — this
// component only owns the truncate + measure + tooltip behavior.
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
}: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [text]);
const span = (
<span ref={ref} className={className}>
{text}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
{span}
</Tooltip>
);
};

View File

@@ -22,7 +22,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
{...props}
/>
);

View File

@@ -1,5 +1,5 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, Copy } from "lucide-react";
import { Check, Copy, Loader2 } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
import { cn } from "@/lib/cn";
@@ -10,12 +10,16 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
// When true, the content is replaced by a centered spinner while keeping
// the button's rendered width/height (the content stays in the layout,
// just hidden). Also disables the button.
loading?: boolean;
}
const buttonVariants = cva(
[
"relative",
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none",
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none cursor-default",
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
],
@@ -93,7 +97,7 @@ const buttonVariants = cva(
},
size: {
xs: "text-xs py-2.5 px-3.5",
xs2: "text-[0.78rem] py-2 px-4",
xs2: "text-[0.78rem] py-[1.1rem] px-4 leading-[0]",
sm: "text-sm py-[9px] px-4",
md: "py-[9px] px-4",
lg: "text-lg py-[9px] px-4",
@@ -124,6 +128,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
onClick,
disabled,
copy,
loading = false,
...props
},
ref,
@@ -134,7 +139,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
<button
ref={ref}
type={type}
disabled={disabled}
disabled={disabled || loading}
className={cn(
buttonVariants({
variant,
@@ -159,8 +164,16 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
}}
{...props}
>
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
{children}
{loading && (
<span className={"absolute inset-0 flex items-center justify-center"}>
<Loader2 size={iconSize} className={"animate-spin"} />
</span>
)}
<span className={cn("contents", loading && "invisible")}>
{copy !== undefined &&
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
{children}
</span>
</button>
);
});

View File

@@ -24,7 +24,7 @@ export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(func
<div
ref={ref}
className={cn(
"flex flex-col items-center gap-5 text-center px-8 py-6",
"flex flex-col items-center gap-5 text-center px-8 pt-6 pb-7",
isMacOS() && "pt-10",
)}
>

View File

@@ -0,0 +1,110 @@
import { ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog";
import { Button } from "@/components/buttons/Button";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogActions } from "@/components/dialog/DialogActions";
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
// title + (optionally multi-line) description with Cancel / confirm buttons
// in the footer. It's the in-window counterpart to the native warningDialog.
//
// Most call sites should not render this directly: use the imperative
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
// single instance at the provider level. Render ConfirmModal yourself only
// when you need bespoke control over its open/busy lifecycle.
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
/** Confirm button label. */
confirmLabel: string;
/** Cancel button label; defaults to the shared "Cancel" string. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
/** Disable the buttons (and ignore dismiss) while an action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export const ConfirmModal = ({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
// Retain the last shown content so it stays rendered through Radix's
// close animation instead of blanking out the instant the caller clears
// its state on close.
type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & {
cancelLabel: string;
};
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const resolvedCancel = cancelLabel ?? t("common.cancel");
useEffect(() => {
if (open) {
setSnapshot({ title, description, confirmLabel, cancelLabel: resolvedCancel, danger });
}
}, [open, title, description, confirmLabel, resolvedCancel, danger]);
const view = open
? { title, description, confirmLabel, cancelLabel: resolvedCancel, danger }
: snapshot;
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
}}
>
<Dialog.Content
maxWidthClass="max-w-sm"
showClose={false}
className="py-5"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{view && (
<div className="flex flex-col gap-5 px-5">
<div className="flex flex-col gap-1 pl-1">
<DialogHeading align={"left"}>{view.title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
{view.description}
</DialogDescription>
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button
variant={"secondary"}
size={"xs2"}
disabled={busy}
onClick={onCancel}
>
{view.cancelLabel}
</Button>
<Button
autoFocus
variant={view.danger ? "danger" : "primary"}
size={"xs2"}
disabled={busy}
onClick={onConfirm}
>
{view.confirmLabel}
</Button>
</DialogActions>
</div>
)}
</Dialog.Content>
</Dialog.Root>
);
};

View File

@@ -2,6 +2,7 @@ import { forwardRef, ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
export const Root = DialogPrimitive.Root;
@@ -15,7 +16,7 @@ const Overlay = forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/40 backdrop-blur-sm",
"bg-black/60",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"duration-150 ease-out",
@@ -36,6 +37,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{ className, children, showClose = true, maxWidthClass = "max-w-md", ...props },
ref,
) {
const { t } = useTranslation();
return (
<DialogPrimitive.Portal>
<Overlay>
@@ -67,7 +69,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
"text-nb-gray-300 hover:text-nb-gray-100",
"focus:outline-none disabled:pointer-events-none",
)}
aria-label="Close"
aria-label={t("common.close")}
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>

View File

@@ -3,11 +3,32 @@ import { cn } from "@/lib/cn";
// DialogDescription is the supporting description text rendered under a
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
type DialogDescriptionProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogDescription = ({ children, className }: DialogDescriptionProps) => (
<p className={cn("text-sm text-nb-gray-300 select-none", className)}>{children}</p>
export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => (
// w-full for the same reason DialogHeading carries it — see the
// comment there. The default text-center remains visually identical
// to before; left/right alignment now anchors to the dialog content
// edge instead of collapsing to no-op on a content-width box.
<p
className={cn(
"w-full text-sm text-nb-gray-300 select-none",
alignClass[align],
className,
)}
>
{children}
</p>
);

View File

@@ -4,13 +4,34 @@ import { cn } from "@/lib/cn";
// DialogHeading is the title text used inside ConfirmDialog (and any other
// dialog-style surface with the same shape). Pair with DialogDescription
// for the standard title/description stack.
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
type DialogHeadingProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogHeading = ({ children, className }: DialogHeadingProps) => (
<p className={cn("text-base font-semibold text-nb-gray-50 select-none", className)}>
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
// w-full so the alignClass actually has a box to anchor against.
// The wrapping <p> defaulted to content width inside a flex column,
// which made `text-left` a no-op (nothing to push the text away
// from). Stretching the element is invisible for the default
// text-center case (center of content == center of box) and lets
// text-left/right line up with the dialog's content edge.
<p
className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none",
alignClass[align],
className,
)}
>
{children}
</p>
);

View File

@@ -31,11 +31,11 @@ export const EmptyState = ({
<div className={cn("py-12 text-center", className)}>
<div
className={
"flex flex-col items-center justify-center max-w-sm mx-auto relative top-7"
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-6"
}
>
<SquareIcon icon={icon} className={"mb-3"} />
<p className={"text-base font-medium text-nb-gray-200 mb-1"}>{title}</p>
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-350"}>{description}</p>}
{learnMoreUrl && learnMoreTopic && (
<p className={"text-sm text-nb-gray-350"}>

View File

@@ -16,7 +16,7 @@ export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) =>
icon={icon}
title={title ?? t("common.noResults.title")}
description={description ?? t("common.noResults.description")}
className={"relative -top-3.5"}
className={"relative top-[2.8rem]"}
/>
);
};

View File

@@ -7,7 +7,7 @@ export const NotConnectedState = () => {
return (
<div
className={
"h-full min-h-[260px] flex-1 flex items-center justify-center px-6 pb-20 top-1 relative"
"h-full flex-1 flex items-start justify-center pt-36 top-[0.6rem] px-6 relative"
}
>
<EmptyState

View File

@@ -1,6 +1,7 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Label } from "@/components/typography/Label";
@@ -13,6 +14,10 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
maxWidthClass?: string;
icon?: ReactNode;
error?: string;
// A soft, non-blocking caveat rendered in orange (vs. error's red). Used
// e.g. for "couldn't reach this server" where the value is syntactically
// fine and the user may still proceed. `error` takes precedence.
warning?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
copy?: boolean;
@@ -33,6 +38,10 @@ const inputVariants = cva("", {
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
],
warning: [
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-orange-400 text-orange-400",
"ring-offset-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10 focus-visible:ring-orange-400/10",
],
},
prefixSuffixVariant: {
default: [
@@ -53,6 +62,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
icon,
maxWidthClass = "",
error,
warning,
variant = "default",
prefixClassName,
showPasswordToggle = false,
@@ -62,6 +72,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
},
ref,
) {
const { t } = useTranslation();
const [showPassword, setShowPassword] = useState(false);
const [copied, setCopied] = useState(false);
const isPasswordType = type === "password";
@@ -103,7 +114,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type="button"
onClick={() => setShowPassword((s) => !s)}
className="hover:text-white transition-all pointer-events-auto"
aria-label="Toggle password visibility"
aria-label={t("common.togglePasswordVisibility")}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
@@ -126,7 +137,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type="button"
onClick={onCopy}
className="hover:text-white transition-all pointer-events-auto"
aria-label="Copy"
aria-label={t("common.copy")}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
@@ -174,7 +185,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{...props}
className={cn(
inputVariants({
variant: error ? "error" : variant,
variant: error ? "error" : warning ? "warning" : variant,
}),
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
"file:bg-transparent file:text-sm file:font-medium file:border-0",
@@ -217,7 +228,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
<button
type="button"
tabIndex={-1}
aria-label="Increase"
aria-label={t("common.increase")}
onClick={() => stepBy(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
@@ -226,7 +237,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
<button
type="button"
tabIndex={-1}
aria-label="Decrease"
aria-label={t("common.decrease")}
onClick={() => stepBy(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
@@ -238,9 +249,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
</div>
)}
</div>
{error && (
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
{error}
{(error || warning) && (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
)}
</div>

View File

@@ -0,0 +1,74 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
// DialogContext exposes an imperative `confirm(...)` that resolves to a
// boolean — the in-app equivalent of the native warningDialog promise. The
// single <ConfirmModal/> lives here at the provider level, so call sites
// just `await confirm({...})` instead of each wiring up their own modal
// component + open/busy state.
//
// const confirm = useConfirm();
// if (await confirm({ title, description, confirmLabel })) { …do it… }
//
// Mounted once (outermost in AppLayout) so it's available in every in-window
// route across both the main and settings windows.
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
};
type DialogContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
// Resolve the pending promise and start the close animation. The options
// stay in state so ConfirmModal still has content to render while it
// animates out.
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
return (
<DialogContext.Provider value={{ confirm }}>
{children}
<ConfirmModal
open={open}
title={options?.title ?? ""}
description={options?.description ?? ""}
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
/>
</DialogContext.Provider>
);
}
export const useConfirm = () => {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
return ctx.confirm;
};

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks" | "exitNode";
export type NavSection = "peers" | "networks";
type NavSectionContextValue = {
section: NavSection;

View File

@@ -22,7 +22,16 @@ import i18next from "@/lib/i18n";
// the observer can settle on a stale size before React's commit and the
// font's glyph metrics finish updating. An explicit double-rAF after the
// language flip guarantees the final layout is the one we measure.
export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
//
// `ready` (default true) gates Window.SetSize + Window.Show. Pass false
// while the caller is still resolving its initial content (e.g. waiting
// on an async probe) so the window stays Hidden instead of briefly
// rendering placeholder padding at the wrong size — Linux/GNOME in
// particular paints whatever the frame ends up at, and a transient
// half-height frame can leak through. Flip ready=true once the real
// content is in the DOM; the effect re-runs, measures the final size,
// and shows the window.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
const el = ref.current;
@@ -31,6 +40,7 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
let raf1 = 0;
let raf2 = 0;
const apply = () => {
if (!ready) return;
const h = Math.ceil(el.getBoundingClientRect().height);
if (h <= 0) return;
// Wails Window.SetSize takes the *frame* size on every platform
@@ -79,6 +89,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
cancelAnimationFrame(raf2);
i18next.off("languageChanged", scheduleApply);
};
}, [width]);
}, [width, ready]);
return ref;
}

View File

@@ -1,23 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { warningDialog } from "@/lib/dialogs.ts";
import i18next from "@/lib/i18n";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/contexts/SettingsContext.tsx";
export enum ManagementMode {
Cloud = "cloud",
SelfHosted = "selfhosted",
}
import { useConfirm } from "@/contexts/DialogContext.tsx";
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
const URL_PATTERN = new RegExp(
// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment].
// Host is domain, localhost, or IPv4. Used for syntactic validation only —
// reachability is checked separately via checkManagementUrlReachable.
export const URL_PATTERN = new RegExp(
"^(https?:\\/\\/)?" +
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
"((\\d{1,3}\\.){3}\\d{1,3}))" +
@@ -27,17 +18,67 @@ const URL_PATTERN = new RegExp(
"i",
);
function isValidManagementUrl(input: string): boolean {
// normalizeManagementUrl prefixes an https:// scheme when the user omits
// it. Empty input stays empty.
export function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not
// touch the network.
export function isValidManagementUrl(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
return URL_PATTERN.test(trimmed);
}
// isCloudManagementUrl reports whether the stored URL is the NetBird
// Cloud default (or an empty/unset URL, which the daemon also treats as
// cloud-defaulting on first boot).
export function isCloudManagementUrl(url: string): boolean {
if (!url || url.trim() === "") return true;
return url === CLOUD_MANAGEMENT_URL;
}
// checkManagementUrlReachable does a best-effort no-cors GET against the
// URL with a short timeout. A resolved fetch (even opaque) means DNS +
// TCP + TLS landed; any rejection (network error, DNS, abort) is treated
// as unreachable. Self-hosted deployments behind internal-only DNS or
// with self-signed certs may return false positives — callers should
// surface this as a soft warning, not a hard block.
export async function checkManagementUrlReachable(
url: string,
timeoutMs: number = 5000,
): Promise<boolean> {
const target = normalizeManagementUrl(url);
if (!target) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal });
return true;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
export enum ManagementMode {
Cloud = "cloud",
SelfHosted = "selfhosted",
}
function modeFromUrl(url: string): ManagementMode {
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
}
export function useManagementUrl() {
const { t } = useTranslation();
const confirm = useConfirm();
const { config, saveField } = useSettings();
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
@@ -45,11 +86,11 @@ export function useManagementUrl() {
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
// Guard against double-showing the cloud-switch confirmation when the
// user toggles the segmented control multiple times before the prior
// Dialogs.Warning promise resolves. Without it each click queues a
// fresh native dialog and the user sees them stack up.
const switchConfirmOpenRef = useRef(false);
// Self-hosted reachability soft-check, mirrored from the onboarding /
// profile-creation flows: a failed probe is a non-blocking orange warning,
// and a second Save with the same URL goes through regardless.
const [checking, setChecking] = useState(false);
const [unreachable, setUnreachable] = useState(false);
useEffect(() => {
setModeState(modeFromUrl(config.managementUrl));
@@ -58,34 +99,27 @@ export function useManagementUrl() {
}
}, [config.managementUrl]);
const setMode = (next: ManagementMode) => {
// Clear the stale warning whenever the target changes.
useEffect(() => {
setUnreachable(false);
}, [url, mode]);
const setMode = async (next: ManagementMode) => {
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
// Switching from a self-hosted management server to NetBird Cloud
// re-points the client at a different deployment and forces a
// reconnect/re-login. Confirm before applying.
if (switchConfirmOpenRef.current) return;
switchConfirmOpenRef.current = true;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
void warningDialog({
Title: i18next.t("settings.general.management.switchCloudTitle"),
Message: i18next.t("settings.general.management.switchCloudMessage"),
Buttons: [
{ Label: cancelLabel, IsCancel: true, IsDefault: true },
{ Label: confirmLabel },
],
})
.then((result) => {
if (result !== confirmLabel) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
})
.finally(() => {
switchConfirmOpenRef.current = false;
});
// reconnect/re-login. Confirm via the in-app modal before applying.
const ok = await confirm({
title: t("settings.general.management.switchCloudTitle"),
description: t("settings.general.management.switchCloudMessage"),
confirmLabel: t("settings.general.management.switchCloudConfirm"),
});
if (!ok) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
return;
}
setModeState(next);
@@ -101,7 +135,22 @@ export function useManagementUrl() {
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const save = () => saveField("managementUrl", targetUrl);
const save = async () => {
// Self-hosted: probe the server first. A failed probe surfaces a soft
// warning and bails; a second Save (unreachable already set) skips the
// re-check and saves anyway, so the user can override a false negative.
if (mode === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(targetUrl);
setChecking(false);
if (!reachable) {
setUnreachable(true);
return;
}
}
await saveField("managementUrl", targetUrl);
setUnreachable(false);
};
return {
mode,
@@ -112,5 +161,7 @@ export function useManagementUrl() {
showError,
canSave,
save,
checking,
unreachable,
};
}

View File

@@ -3,6 +3,7 @@ import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
import { StatusProvider } from "@/contexts/StatusContext.tsx";
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
// Shared shell for every in-window route (main + settings). Owns the daemon-
// availability gate (via StatusProvider) and the providers every page needs.
@@ -14,15 +15,17 @@ import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
export const AppLayout = () => {
return (
<div className={"relative flex h-full flex-col"}>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
<DialogProvider>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
</DialogProvider>
</div>
);
};

View File

@@ -6,6 +6,7 @@ type Props = {
children: ReactNode;
overlay?: ReactNode;
overlayOpen?: boolean;
className?: string;
};
// iOS-style push transition: incoming pane slides in from the right while
@@ -16,13 +17,14 @@ const PANEL_TRANSITION = {
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
};
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
return (
<div
className={cn(
"wails-no-draggable relative m-4",
"wails-no-draggable relative m-5",
"bg-nb-gray-940 border border-nb-gray-920",
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
className,
)}
>
<motion.div

View File

@@ -29,17 +29,21 @@ for (const path in bundleModules) {
}
// detectBrowserLanguage walks navigator.language + navigator.languages
// and returns the first base code ("de" from "de-DE") that has a shipped
// bundle. Returns null when none match, so the caller can fall back to
// English. We only ever match against the lowercased base — region tags
// don't have separate bundles today.
// and returns the first shipped bundle that matches. We try an exact
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
// null when nothing matches, so the caller can fall back to English.
function detectBrowserLanguage(available: string[]): string | null {
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
(tag): tag is string => typeof tag === "string" && tag.length > 0,
);
const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
for (const tag of tags) {
const base = tag.toLowerCase().split("-")[0];
if (available.includes(base)) return base;
const lower = tag.toLowerCase();
const exact = byLower.get(lower);
if (exact) return exact;
const base = byLower.get(lower.split("-")[0]);
if (base) return base;
}
return null;
}

View File

@@ -10,45 +10,68 @@ import { useProfile } from "@/contexts/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import { formatErrorMessage } from "@/lib/errors.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
// device code.
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
// The tray emits it from menu items so the React UI (which owns the SSO
// orchestration and the browser-login window) takes over.
const EVENT_TRIGGER_LOGIN = "trigger-login";
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
// popup window is the only login UI; errors surface as a native
// Dialogs.Error. Concurrent calls are dropped via the inFlight guard.
// loginInFlight is a module-level guard. SSO login involves multiple async
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
// call would race on the daemon's pending device code and on the popup
// window's singleton, leading to confusing UX. Calls past the first are
// dropped silently — the first invocation owns the flow until it settles.
let loginInFlight = false;
async function startLogin(): Promise<void> {
if (loginInFlight) return;
// startLogin drives the daemon's SSO login end-to-end:
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
// 3. Race WaitSSOLogin vs the user clicking Cancel.
// 4. On success: Connection.Up.
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
// drops the abandoned device code (avoids an Idle blink on the tray).
//
// Errors that aren't user cancellations surface via errorDialog. Concurrent
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
// all exit paths so a stray popup doesn't outlive the flow.
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
// instant the flow itself is over (success, cancel, or error) — BEFORE the
// error dialog is shown. Every guard that gates re-arming the login path
// (the module-level loginInFlight here, and the caller's React-level
// loginGuard via onSettled) must be released at that point, never gated on
// the dialog.
//
// Why the dialog must be outside the guards: the native Windows MessageBox
// disables its parent for its whole lifetime, and the main window's
// WindowClosing hook hides instead of closing — the two race and the dialog
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
// release awaited the dialog, that guard would stay held for as long as the
// box is open (or forever if it hangs), and every later Connect / tray
// trigger-login would be silently dropped at the guard check until the
// client is restarted. That was the original "can't log in again until
// restart" bug.
async function startLogin(onSettled?: () => void): Promise<void> {
if (loginInFlight) {
// The caller's guard must still be released — it was set before this
// call. Without this the React-level loginGuard would wedge on a
// dropped concurrent invocation.
onSettled?.();
return;
}
loginInFlight = true;
let cancelled = false;
let offCancel: (() => void) | undefined;
let loginError: unknown;
try {
const result = await Connection.Login({
@@ -64,10 +87,6 @@ async function startLogin(): Promise<void> {
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) {
// Open the in-app sign-in popup first; the dialog itself
// fires Connection.OpenURL after it's actually on screen
// (see WaitingForBrowserDialog) so the system browser
// doesn't land on top of a still-hidden NetBird window.
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
@@ -94,12 +113,6 @@ async function startLogin(): Promise<void> {
}
if (cancelled) {
// Cancel the in-flight WaitSSOLogin gRPC instead of a heavy
// Down. The daemon ties the wait to this call's context
// (server.go WaitSSOLogin), so cancelling ends the wait and
// clears the abandoned OAuth flow — a fresh Login then starts
// a new device code, with no Idle blink on the tray. Swallow
// the cancellation rejection on the abandoned promise.
waitPromise.cancel?.();
void waitPromise.catch(() => {});
return;
@@ -109,17 +122,48 @@ async function startLogin(): Promise<void> {
await Connection.Up({ profileName: "", username: "" });
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);
if (cancelled) return;
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: errorMessage(e),
});
if (!cancelled) loginError = e;
} finally {
offCancel?.();
// Release every guard before any UI work below — never gate re-arming
// the login path on a dialog that can hang. loginInFlight is ours;
// onSettled releases the caller's React-level loginGuard.
loginInFlight = false;
onSettled?.();
}
if (loginError !== undefined) {
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: formatErrorMessage(loginError),
});
}
}
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
@@ -151,7 +195,12 @@ export const MainConnectionStatusSwitch = () => {
if (loginGuard.current) return;
loginGuard.current = true;
setAction("logging-in");
void startLogin().finally(() => {
// Release the React-level guard via onSettled — fired the instant the
// flow ends, before startLogin's error dialog. Gating it on the full
// startLogin() promise would keep loginGuard wedged for the whole
// dialog lifetime, leaving the tray's trigger-login dropped at the
// guard check until the client is restarted.
void startLogin(() => {
loginGuard.current = false;
setAction(null);
void refresh();
@@ -343,7 +392,11 @@ export const MainConnectionStatusSwitch = () => {
const ip = status?.local.ip || "";
return (
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
<div
className={cn(
"flex flex-col h-full w-full items-center justify-center gap-4 relative -top-6",
)}
>
<img
src={netbirdFullLogo}
alt={"NetBird"}
@@ -370,14 +423,17 @@ export const MainConnectionStatusSwitch = () => {
<CopyToClipboard
message={fqdn}
className={cn(
"min-h-[1em] transition-opacity duration-300",
"min-h-[1em] transition-opacity duration-300 max-w-full",
"relative left-[0.55rem]",
showLocal && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
{fqdn || " "}
</span>
<TruncatedText
text={fqdn || " "}
className={
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px]"
}
/>
</CopyToClipboard>
<CopyToClipboard
message={ip}
@@ -387,7 +443,7 @@ export const MainConnectionStatusSwitch = () => {
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>

View File

@@ -0,0 +1,215 @@
import { forwardRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronsUpDown, LucideProps, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const active = exitNodes.find((n) => n.selected) ?? null;
const isConnected = status?.status === "Connected";
const hasAny = exitNodes.length > 0;
const disabled = !isConnected || !hasAny;
const [open, setOpen] = useState(false);
const handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active) void toggleExitNode(active.id, true);
return;
}
if (active && active.id === next) return;
void toggleExitNode(next, false);
};
const title = active ? active.id : t("exitNodes.card.title");
const description = !hasAny
? t("exitNodes.empty.title")
: active
? t("exitNodes.card.statusActive")
: t("exitNodes.card.statusInactive");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ExitNodeTriggerCard
title={title}
description={description}
disabled={disabled}
active={!!active}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
side={"top"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: "var(--radix-popover-trigger-width)" }}
className={cn(
"z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
)}
>
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
<Command.List>
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
key={n.id}
id={n.id}
label={n.id}
isActive={active?.id === n.id}
onSelect={() => handleSelect(n.id)}
/>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
title: string;
description: string;
active?: boolean;
};
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
function ExitNodeTriggerCard(
{ title, description, disabled, active = false, className, ...props },
ref,
) {
return (
<button
ref={ref}
type={"button"}
disabled={disabled}
className={cn(
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
"border border-nb-gray-920 bg-nb-gray-940",
"transition-colors duration-150",
"wails-no-draggable",
disabled
? "opacity-60 cursor-not-allowed"
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
className,
)}
{...props}
>
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
active
? "bg-green-500/25 text-green-400"
: "bg-nb-gray-900 text-nb-gray-300",
)}
>
<ExitNodeIcon size={14} />
</div>
<div className={"min-w-0 flex-1"}>
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
<TruncatedText
text={description}
className={
"block text-[0.85rem] font-medium text-nb-gray-400 truncate max-w-full"
}
/>
</div>
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
</button>
);
},
);
type NoneRowProps = {
isActive: boolean;
onSelect: () => void;
};
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
const { t } = useTranslation();
return (
<Command.Item
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex gap-2 items-center px-2 py-2 pr-3",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
</Command.Item>
);
};
type ExitNodeRowProps = {
id: string;
label: string;
isActive: boolean;
onSelect: () => void;
};
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
<Command.Item
value={id}
onSelect={onSelect}
className={cn(
"flex gap-2 items-center px-2 py-2 pr-3",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
</Command.Item>
);
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);

View File

@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import {isWindows} from "@/lib/platform.ts";
import { isWindows } from "@/lib/platform.ts";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -143,21 +143,24 @@ export const MainHeader = () => {
return (
<div
className={cn(
"shrink-0 cursor-default wails-draggable relative",
"flex items-center h-12 top-2.5",
"shrink-0 cursor-default wails-draggable relative z-10",
"flex items-center h-12 top-3",
)}
>
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("grid grid-cols-3 items-center shrink-0", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div
className={cn(
"grid grid-cols-3 items-center shrink-0",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<div />
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
{settingsSlot}
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
import { MainHeader } from "@/modules/main/MainHeader.tsx";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
@@ -9,7 +10,6 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
@@ -37,8 +37,11 @@ const MainBody = () => {
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
<MainConnectionStatusSwitch />
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
<MainExitNodeSwitcher />
</div>
</div>
{isAdvanced && (
<NavSectionProvider>
@@ -56,7 +59,11 @@ const AdvancedAppRightPanel = () => {
const isConnected = status?.status === "Connected";
return (
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
>
<div
className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col",
@@ -68,7 +75,6 @@ const AdvancedAppRightPanel = () => {
<div className={"flex-1 min-h-0 flex flex-col"}>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "exitNode" && <ExitNodes />}
</div>
</div>
{!isConnected && (

View File

@@ -1,6 +1,6 @@
import { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
@@ -28,11 +28,6 @@ export const Navigation = () => {
label: t("nav.resources.title"),
icon: Layers3Icon,
},
{
value: "exitNode",
label: t("nav.exitNode.title"),
icon: ExitNodeIcon,
},
];
return (
@@ -72,12 +67,4 @@ export const Navigation = () => {
);
};
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);
export type { NavSection } from "@/contexts/NavSectionContext";

View File

@@ -1,182 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as RadioGroup from "@radix-ui/react-radio-group";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { WaypointsIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const ExitNodes = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Initial order: active-first, then by id. After that, positions are sticky
// — toggling a row doesn't move it. Mirrors the networks-list behavior so
// the optimistic radio flip paints in place instead of the row jumping to
// the top.
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const byId = new Map(exitNodes.map((n) => [n.id, n]));
const kept = orderRef.current.filter((id) => byId.has(id));
const known = new Set(kept);
const fresh = exitNodes
.filter((n) => !known.has(n.id))
.sort((a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
})
.map((n) => n.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
}, [exitNodes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return ordered;
return ordered.filter((r) => r.id.toLowerCase().includes(q));
}, [ordered, search]);
if (isConnected && exitNodes.length === 0) {
return (
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={WaypointsIcon}
title={t("exitNodes.empty.title")}
description={t("exitNodes.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
learnMoreTopic={t("nav.exitNode.title")}
/>
</div>
);
}
return (
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</div>
);
};
type ExitNodesListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
};
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
const { t } = useTranslation();
const active = data.find((n) => n.selected) ?? null;
const value = active?.id ?? NONE_VALUE;
const handleChange = (next: string) => {
if (next === value) return;
if (next === NONE_VALUE) {
if (active) onToggle(active.id, true);
return;
}
onToggle(next, false);
};
return (
<RadioGroup.Root
value={value}
onValueChange={handleChange}
className={"flex flex-col"}
>
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
{data.map((n) => (
<Row key={n.id} value={n.id} label={n.id} />
))}
</RadioGroup.Root>
);
};
type RowProps = {
value: string;
label: string;
first?: boolean;
};
const Row = ({ value, label, first }: RowProps) => (
<RadioGroup.Item
value={value}
className={cn(
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
first && "mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer outline-none text-left",
)}
>
<span
className={
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{label}
</span>
<span
className={cn(
"h-4 w-4 shrink-0 rounded-full border",
"border-nb-gray-700 bg-nb-gray-900",
"flex items-center justify-center",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator
className={"h-2 w-2 rounded-full bg-white"}
/>
</span>
</RadioGroup.Item>
);

View File

@@ -39,23 +39,17 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-100",
"text-sm text-nb-gray-200",
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
"disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable",
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} className={"shrink-0"} />
<span>
{active.label}{" "}
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
@@ -76,11 +77,7 @@ export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const {
networkRoutes: realNetworkRoutes,
toggleNetwork,
setNetworksSelected,
} = useNetworks();
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
@@ -164,9 +161,7 @@ export const Networks = () => {
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected
? t("networks.bulk.disableAll")
: t("networks.bulk.enableAll");
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
@@ -262,7 +257,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
key={n.id}
onClick={() => onToggle(n.id, n.selected)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2",
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
)}
@@ -271,29 +266,21 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={n.id}>
<span
<TruncatedText
text={n.id}
className={
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
>
{n.id}
</span>
/>
</CopyToClipboard>
</div>
<Subtitle network={n} />
</div>
<div
className={"shrink-0 self-center"}
onClick={(e) => e.stopPropagation()}
>
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
<NetworkToggle
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
label={
n.selected
? t("networks.selected")
: t("networks.unselected")
}
label={n.selected ? t("networks.selected") : t("networks.unselected")}
/>
</div>
</li>
@@ -307,7 +294,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
return (
<div
className={cn(
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
)}
>
@@ -327,9 +314,12 @@ const Subtitle = ({ network }: { network: Network }) => {
return (
<div>
<CopyToClipboard message={network.range}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{network.range}
</span>
<TruncatedText
text={network.range}
className={
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
}
/>
</CopyToClipboard>
</div>
);
@@ -339,90 +329,63 @@ const Subtitle = ({ network }: { network: Network }) => {
};
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
const first = ips[0];
const extra = ips.length - 1;
const span = (
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
{domain}
</span>
);
return (
<>
<div>
<CopyToClipboard message={domain}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{domain}
</span>
</CopyToClipboard>
</div>
{first && (
<div className={"flex items-center gap-1.5 min-w-0"}>
<CopyToClipboard message={first}>
<span className={"text-xs font-mono text-nb-gray-500 truncate"}>
{first}
</span>
</CopyToClipboard>
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
</div>
)}
</>
<div>
<CopyToClipboard message={domain}>
{ips.length > 0 ? (
<Tooltip
content={<ResolvedIpsTooltip ips={ips} />}
delayDuration={300}
closeDelay={300}
side={"right"}
align={"start"}
alignOffset={-8}
interactive
keepOpenOnClick
contentClassName={cn(
"max-w-[18rem] max-h-72 overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 pr-4",
)}
>
{span}
</Tooltip>
) : (
span
)}
</CopyToClipboard>
</div>
);
};
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
const { t } = useTranslation();
const extra = ips.length - 1;
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type={"button"}
onClick={(e) => e.stopPropagation()}
className={cn(
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
"wails-no-draggable cursor-pointer outline-none",
)}
>
{t("networks.ips.more", { count: extra })}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"start"}
sideOffset={6}
className={cn(
"z-50 w-64 max-h-72 overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 shadow-lg outline-none",
)}
>
<div
className={
"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-500"
}
>
{t("networks.ips.heading")}
</div>
<ul className={"flex flex-col"}>
{ips.map((ip) => (
<li key={ip}>
<CopyToClipboard
message={ip}
className={"px-1 py-0.5"}
>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
}
>
{ip}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<>
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
{t("networks.ips.heading")}
</div>
<ul className={"flex flex-col"}>
{ips.map((ip) => (
<li key={ip}>
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-100 whitespace-nowrap"
}
>
{ip}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</>
);
};
@@ -450,11 +413,7 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed
? "translate-x-2.5"
: checked
? "translate-x-[1.125rem]"
: "translate-x-0.5",
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
)}
/>
</button>

View File

@@ -1,12 +1,4 @@
import {
ComponentType,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as Popover from "@radix-ui/react-popover";
@@ -16,7 +8,8 @@ import {
ArrowLeftIcon,
ArrowUpDownIcon,
ArrowUpIcon,
CableIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
ClockIcon,
GaugeIcon,
HandshakeIcon,
@@ -25,15 +18,15 @@ import {
LucideProps,
MapPinIcon,
MonitorIcon,
NetworkIcon,
Radio,
RefreshCwIcon,
ZapIcon,
WaypointsIcon,
} from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
@@ -222,7 +215,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
const isConnected = peer.connStatus === "Connected";
const ConnectionIcon = peer.relayed ? NetworkIcon : ZapIcon;
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
return (
@@ -242,11 +234,24 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
)}
</Row>
{isConnected && (
<Row icon={CableIcon} label={t("peers.details.connection")}>
<span className={"inline-flex items-center gap-1.5 whitespace-nowrap"}>
<ConnectionIcon size={13} />
{connectionLabel}
</span>
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
</Row>
)}
{peer.relayed && (
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
{peer.relayAddress ? (
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
)}
{peer.latencyMs > 0 && (
@@ -282,6 +287,11 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
{statusSince}
</Row>
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<IceRow
icon={MonitorIcon}
baseLabel={t("peers.details.localIce")}
@@ -294,27 +304,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
type={peer.remoteIceCandidateType}
endpoint={peer.remoteIceCandidateEndpoint}
/>
{peer.relayed && (
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
{peer.relayAddress ? (
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
)}
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
{peer.pubKey ? (
<CopyToClipboard
@@ -370,67 +359,35 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
);
};
// "{N} Resources" trigger that opens a hover popover listing each routed
// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs
// popover in the Resources tab (Networks.tsx ResolvedIpsPopover).
// Row value: first network inline, plus a "+N more" hover pill opening a
// popover with the full list. Mirrors the resolved-IPs pattern in
// Networks.tsx so the Resources tab and the peer detail row look consistent.
const ResourcesValue = ({ networks }: { networks: string[] }) => {
const first = networks[0];
const extra = networks.length - 1;
return (
<div className={"flex items-center gap-1.5 min-w-0 justify-end"}>
<CopyToClipboard message={first}>
<TruncatedRowValue value={first} mono />
</CopyToClipboard>
{extra > 0 && <ResourcesMorePopover networks={networks} extra={extra} />}
</div>
);
};
// Single "View {n}" badge with a chevron that opens a click popover listing
// each routed resource on its own line with a click-to-copy entry. Avoids
// the repetitive "first item + N more" pattern given the row already has a
// "Resources" label and Layers icon.
const ResourcesValue = ({ networks }: { networks: string[] }) => (
<ResourcesPopover networks={networks} />
);
const ResourcesMorePopover = ({
networks,
extra,
}: {
networks: string[];
extra: number;
}) => {
const { t } = useTranslation();
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
// Close with a small delay so the mouse can cross the sideOffset gap
// between trigger and content without the popover snapping shut.
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 300);
};
useEffect(() => () => cancelClose(), []);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
className={cn(
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none",
"shrink-0 inline-flex items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"px-2 py-1 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
)}
>
{t("peers.details.more", { count: extra })}
{networks.length}
<ChevronDownIcon
size={12}
className={cn("transition-transform duration-150", open && "rotate-180")}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
@@ -438,13 +395,11 @@ const ResourcesMorePopover = ({
side={"bottom"}
align={"end"}
sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 w-64 max-h-72 overflow-auto",
"z-50 max-w-[18rem] max-h-72 overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 shadow-lg outline-none",
"p-2 pr-4 shadow-lg outline-none",
)}
>
<ul className={"flex flex-col"}>
@@ -453,7 +408,7 @@ const ResourcesMorePopover = ({
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
"font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
}
>
{n}
@@ -468,37 +423,15 @@ const ResourcesMorePopover = ({
);
};
// Truncates the value to one line with the row's available width; on hover,
// shows the full string in a tooltip — but only when actually clipped. Same
// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown.
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [value]);
const span = (
<span
ref={ref}
className={cn(
"inline-block truncate align-middle min-w-0 max-w-[260px]",
mono && "font-mono",
)}
>
{value}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={value} delayDuration={600}>
{span}
</Tooltip>
);
};
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
className={cn(
"inline-block truncate align-middle min-w-0 max-w-[260px]",
mono && "font-mono",
)}
/>
);
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>

View File

@@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-100",
"text-sm text-nb-gray-200",
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
"disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable cursor-default",
@@ -47,15 +47,9 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
>
<ListFilter size={14} className={"shrink-0"} />
<span>
{active.label}{" "}
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -68,21 +62,10 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>
({counts[f.value]})
</span>
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
<span className={"w-4 shrink-0 flex items-center justify-center"}>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
@@ -12,6 +12,7 @@ import { latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { mockOr, mockPeers } from "@/lib/mock";
import { PeerFilters, StatusFilter } from "./PeerFilters";
@@ -67,27 +68,55 @@ export const Peers = () => {
};
}, [peers]);
// Initial order: online-first, then alphabetically by fqdn / ip. After
// that, positions are sticky — a peer flipping Connected→Connecting→Idle
// no longer jumps groups. Newly discovered peers append at the end
// (sorted online-first / by-name among themselves). Mirrors the
// networks-list and exit-nodes-list orderRef pattern.
// Initial order: online-first, then alphabetically by fqdn / ip. Once
// peers have settled, positions become sticky — a peer flipping
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
// peers append at the end (sorted online-first / by-name among
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
// pattern.
//
// Stay in live-sort mode until every peer has reached a stable state
// (Connected or Idle). The daemon emits all peers as "Connecting" right
// after Up, which collapses the online-first sort into pure
// alphabetical — committing then would lock that incorrect order and
// the list would stay alphabetical even after every peer becomes
// Connected. Once nothing is Connecting we commit and go sticky.
const orderRef = useRef<string[]>([]);
const stickyRef = useRef(false);
const ordered = useMemo(() => {
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
const kept = orderRef.current.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = peers
.filter((p) => !known.has(p.pubKey))
.sort((a, b) => {
const sortOnlineFirst = (list: PeerStatus[]) =>
[...list].sort((a, b) => {
const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName);
})
.map((p) => p.pubKey);
});
// Reset on empty (Disconnect → reconnect) so the next session
// re-sorts from scratch instead of replaying the stale orderRef.
if (peers.length === 0) {
orderRef.current = [];
stickyRef.current = false;
return [];
}
if (!stickyRef.current) {
const sorted = sortOnlineFirst(peers);
if (peers.every((p) => p.connStatus !== "Connecting")) {
orderRef.current = sorted.map((p) => p.pubKey);
stickyRef.current = true;
}
return sorted;
}
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
const kept = orderRef.current.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
(p) => p.pubKey,
);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((k) => byKey.get(k)!);
@@ -171,15 +200,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-default",
)}
>
<Tooltip
content={t(peerStatusLabelKey(peer.connStatus))}
side={"left"}
>
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
@@ -190,7 +216,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={peer.fqdn}>
<TruncatedName name={peer.fqdn} />
<TruncatedText
text={peer.fqdn}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
/>
</CopyToClipboard>
</div>
<div>
@@ -224,35 +255,3 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
</ul>
);
};
// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a
// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout,
// and wrap in a Tooltip only when the text actually overflows. Avoids the
// "tooltip on hover even though everything fits" annoyance.
const TruncatedName = ({ name }: { name: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [name]);
const span = (
<span
ref={ref}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
>
{name}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={name} delayDuration={600}>
{span}
</Tooltip>
);
};

View File

@@ -1,79 +1,216 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusCircle } from "lucide-react";
import * as Dialog from "@/components/dialog/Dialog";
import { Input } from "@/components/inputs/Input";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { Label } from "@/components/typography/Label";
import { HelpText } from "@/components/typography/HelpText";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string) => void;
// onCreate receives the sanitized profile name and the management URL the
// user picked (the cloud default for Cloud mode, the normalized self-
// hosted URL otherwise).
onCreate: (name: string, managementUrl: string) => void;
};
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
// `_` and `-` survive on the Go side. We additionally lowercase and convert
// spaces to `-` so what the user sees in the input is exactly what the
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
// ghost row and breaks subsequent delete.
const sanitizeProfileInput = (value: string): string =>
value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]/g, "");
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
const { t } = useTranslation();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
const [url, setUrl] = useState("");
const [urlError, setUrlError] = useState<string | null>(null);
// unreachable: soft warning. A second submit with the same URL proceeds
// anyway (matches the onboarding management step's behaviour for self-
// hosted servers behind internal DNS / VPN).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) {
setName("");
setError(null);
setNameError(null);
setMode(ManagementMode.Cloud);
setUrl("");
setUrlError(null);
setUnreachable(false);
setChecking(false);
}
}, [open]);
const handleSubmit = (e: FormEvent) => {
// Reset the URL warnings whenever the user edits the URL or flips mode —
// otherwise a stale warning lingers next to a just-corrected value.
useEffect(() => {
setUrlError(null);
setUnreachable(false);
}, [url, mode]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed.length === 0) {
setError(t("profile.dialog.required"));
inputRef.current?.focus();
if (checking) return;
const sanitized = sanitizeProfileInput(name);
if (sanitized.length === 0) {
setNameError(t("profile.dialog.required"));
nameRef.current?.focus();
return;
}
onCreate(trimmed);
if (mode === ManagementMode.Cloud) {
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
onOpenChange(false);
return;
}
const trimmed = url.trim();
if (!trimmed || !isValidManagementUrl(trimmed)) {
setUrlError(t("settings.general.management.urlError"));
urlRef.current?.focus();
return;
}
const target = normalizeManagementUrl(trimmed);
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: soft warning + bail. A second submit with the
// same URL skips re-checking (unreachable still true) so the user can
// proceed if they're sure.
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
onCreate(sanitized, target);
onOpenChange(false);
};
const handleChange = (value: string) => {
setName(value);
if (error) setError(null);
const handleNameChange = (value: string) => {
setName(sanitizeProfileInput(value));
if (nameError) setNameError(null);
};
// Live syntactic feedback: flag a non-empty, malformed URL as the user
// types instead of waiting for submit. Empty is not an error yet (handled
// on submit); the unreachable soft-warning only applies once syntax is OK.
const trimmedUrl = url.trim();
const showUrlSyntaxError =
mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl);
const urlInputError = showUrlSyntaxError
? t("settings.general.management.urlError")
: (urlError ?? undefined);
// Soft, non-blocking caveat (orange) — only when the URL is otherwise OK.
const urlInputWarning =
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Content
maxWidthClass="max-w-md"
showClose={false}
className="py-7"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<form onSubmit={handleSubmit}>
<div className="px-8">
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
<Dialog.Description className="mt-1">
{t("profile.dialog.description")}
</Dialog.Description>
</div>
<div className="flex flex-col gap-6 px-7">
<div className="flex flex-col gap-2">
<div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.description")}
</HelpText>
</div>
<Input
ref={nameRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={nameError ?? undefined}
maxLength={64}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
</div>
<div className="px-8 pt-3">
<Input
ref={inputRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
value={name}
onChange={(e) => handleChange(e.target.value)}
error={error ?? undefined}
/>
</div>
<div className="flex flex-col gap-2">
<div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("settings.general.management.label")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.managementHelp")}
</HelpText>
</div>
<div className="flex flex-col gap-3">
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
{mode === ManagementMode.SelfHosted && (
<Input
ref={urlRef}
autoFocus
placeholder={t("settings.general.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
warning={urlInputWarning}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
)}
</div>
</div>
<Dialog.Footer separator={false} className="pt-4">
<Button
type="submit"
variant="primary"
size={"md"}
className="w-full"
>
<PlusCircle size={14} />
{t("profile.dialog.submit")}
</Button>
</Dialog.Footer>
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
<Button
type="button"
variant={"secondary"}
size={"xs2"}
disabled={checking}
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant={"primary"}
size={"xs2"}
loading={checking}
>
{t("profile.dialog.submit")}
</Button>
</DialogActions>
</div>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,7 +1,7 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
import { errorDialog } from "@/lib/dialogs.ts";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/buttons/Button";
@@ -11,6 +11,10 @@ import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext";
import { useConfirm } from "@/contexts/DialogContext";
import { Settings as SettingsSvc } from "@bindings/services";
import { SetConfigParams } from "@bindings/services/models.js";
import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
@@ -23,20 +27,50 @@ export function ProfilesTab() {
profiles,
activeProfile,
loaded,
username,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
} = useProfile();
const confirm = useConfirm();
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const sorted = [...profiles].sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
});
// The display order is established once — the active profile first, then
// the rest alphabetically — and then held stable for the lifetime of the
// window. Switching profiles must only flip the "active" badge, never
// reorder the rows (otherwise the row the user just clicked jumps to the
// top under their cursor). New profiles append at the end; removed ones
// drop out. `orderRef` is the source of truth for row order; the active
// badge is derived live from `activeProfile`.
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const present = new Set(profiles.map((p) => p.name));
if (orderRef.current.length === 0) {
// First population: active-first, then alphabetical.
orderRef.current = [...profiles]
.sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
})
.map((p) => p.name);
} else {
// Preserve the established order; drop removed, append added.
const kept = orderRef.current.filter((n) => present.has(n));
const added = profiles
.map((p) => p.name)
.filter((n) => !orderRef.current.includes(n))
.sort((a, b) => a.localeCompare(b));
orderRef.current = [...kept, ...added];
}
const byName = new Map(profiles.map((p) => [p.name, p]));
return orderRef.current
.map((n) => byName.get(n))
.filter((p): p is Profile => p !== undefined);
}, [profiles, activeProfile]);
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
@@ -53,40 +87,53 @@ export function ProfilesTab() {
}
};
const handleDeregister = async (name: string) => {
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("profile.deregister.confirm");
const result = await warningDialog({
Title: i18next.t("profile.deregister.title"),
Message: i18next.t("profile.deregister.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
const handleSwitch = async (name: string) => {
const ok = await confirm({
title: t("profile.switch.title", { name }),
description: t("profile.switch.message", { name }),
confirmLabel: t("profile.switch.confirm"),
});
if (result !== confirmLabel) return;
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
};
const handleDeregister = async (name: string) => {
const ok = await confirm({
title: t("profile.deregister.title", { name }),
description: t("profile.deregister.message", { name }),
confirmLabel: t("profile.deregister.confirm"),
});
if (!ok) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
};
const handleDelete = async (name: string) => {
if (name === DEFAULT_PROFILE) return;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("common.delete");
const result = await warningDialog({
Title: i18next.t("profile.delete.title"),
Message: i18next.t("profile.delete.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (result !== confirmLabel) return;
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
};
const handleCreate = async (name: string) => {
const handleCreate = async (name: string, managementUrl: string) => {
try {
await addProfile(name);
// Only persist a management URL for self-hosted; a fresh profile
// already defaults to NetBird Cloud, so writing the cloud URL
// would be a no-op. Do it before switching so any reconnect the
// switch triggers already targets the right deployment. SetConfig
// is keyed by profile name, so it writes the new profile even
// though it isn't active yet (adminUrl left empty — the daemon
// keeps its loaded value).
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: name, username, managementUrl }),
);
}
await switchProfile(name);
} catch (e) {
await errorDialog({
@@ -97,7 +144,7 @@ export function ProfilesTab() {
};
return (
<>
<div>
<SectionGroup title={t("settings.profiles.section.profiles")}>
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
@@ -108,11 +155,12 @@ export function ProfilesTab() {
>
<table className={"w-full text-sm"}>
<tbody>
{sorted.map((profile) => (
{ordered.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onSwitch={() => handleSwitch(profile.name)}
onDeregister={() => handleDeregister(profile.name)}
onDelete={() => handleDelete(profile.name)}
/>
@@ -120,7 +168,7 @@ export function ProfilesTab() {
</tbody>
</table>
{loaded && sorted.length === 0 && (
{loaded && ordered.length === 0 && (
<div
className={
"flex flex-col items-center justify-center py-10 text-center"
@@ -146,18 +194,19 @@ export function ProfilesTab() {
</SectionGroup>
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
</>
</div>
);
}
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onSwitch: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: ProfileRowProps) => {
const { t } = useTranslation();
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
const showEmail = !!profile.email;
@@ -192,8 +241,11 @@ const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowPro
</td>
<td className={"px-4 py-2.5 text-right align-middle"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
canDelete={profile.name !== DEFAULT_PROFILE}
isDefault={profile.name === DEFAULT_PROFILE}
isActive={isActive}
onSwitch={onSwitch}
onDeregister={onDeregister}
onDelete={onDelete}
/>
@@ -222,14 +274,31 @@ const TruncatedEmail = ({ email }: { email: string }) => {
};
type RowActionsProps = {
canSwitch: boolean;
canDeregister: boolean;
canDelete: boolean;
isDefault: boolean;
isActive: boolean;
onSwitch: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => {
const RowActions = ({
canSwitch,
canDeregister,
isDefault,
isActive,
onSwitch,
onDeregister,
onDelete,
}: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
const deleteLabel = isDefault
? t("profile.delete.disabledDefault")
: isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
@@ -239,11 +308,17 @@ const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowAct
hidden={!canDeregister}
/>
<ActionIconButton
label={t("profile.selector.delete")}
label={deleteLabel}
icon={Trash2}
onClick={onDelete}
variant={"danger"}
hidden={!canDelete}
disabled={deleteDisabled}
/>
<ActionIconButton
label={t("profile.selector.switchTo")}
icon={LogIn}
onClick={onSwitch}
hidden={!canSwitch}
/>
</div>
);
@@ -257,6 +332,8 @@ type ActionIconButtonProps = {
/** When true the button still occupies space (preserves row layout)
* but is invisible and non-interactive. */
hidden?: boolean;
/** When true the button is visible but non-interactive (greyed out). */
disabled?: boolean;
};
const ActionIconButton = ({
@@ -265,13 +342,15 @@ const ActionIconButton = ({
onClick,
variant = "default",
hidden = false,
disabled = false,
}: ActionIconButtonProps) => {
const button = (
<button
type={"button"}
onClick={onClick}
onClick={disabled ? undefined : onClick}
aria-label={label}
aria-hidden={hidden || undefined}
aria-disabled={disabled || undefined}
tabIndex={hidden ? -1 : undefined}
className={cn(
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
@@ -280,6 +359,7 @@ const ActionIconButton = ({
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
hidden && "opacity-0 pointer-events-none",
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
)}
>
<Icon size={16} />
@@ -287,7 +367,12 @@ const ActionIconButton = ({
);
if (hidden) return button;
return (
<Tooltip content={label} side={"top"}>
<Tooltip
content={
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
}
side={"top"}
>
{button}
</Tooltip>
);

View File

@@ -58,7 +58,7 @@ export function SettingsAbout() {
return (
<div
className={
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
}
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />

View File

@@ -18,7 +18,8 @@ const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/;
const INTERFACE_NAME_ERROR_KEY = IS_MAC
? "settings.advanced.interfaceName.errorMac"
: "settings.advanced.interfaceName.error";
const PORT_MIN = 1;
// Port 0 means "let the daemon pick a random free port" (see the hint text).
const PORT_MIN = 0;
const PORT_MAX = 65535;
// Mirrors client/iface/iface.go MinMTU / MaxMTU. 576 is the IPv4 "every host
// must accept" datagram size from RFC 791 — safe floor when IPv6 is off; for
@@ -93,20 +94,23 @@ export function SettingsAdvanced() {
}
/>
<div className={"grid grid-cols-2 gap-4"}>
<Input
label={t("settings.advanced.port.label")}
type={"number"}
min={PORT_MIN}
max={PORT_MAX}
value={values.wireguardPort}
error={errors.wireguardPort}
onChange={(e) =>
setValues((v) => ({
...v,
wireguardPort: Number(e.target.value),
}))
}
/>
<div>
<Input
label={t("settings.advanced.port.label")}
type={"number"}
min={PORT_MIN}
max={PORT_MAX}
value={values.wireguardPort}
error={errors.wireguardPort}
onChange={(e) =>
setValues((v) => ({
...v,
wireguardPort: Number(e.target.value),
}))
}
/>
<HelpText className={"mt-1.5"}>{t("settings.advanced.port.help")}</HelpText>
</div>
<Input
label={t("settings.advanced.mtu.label")}
type={"number"}

View File

@@ -15,7 +15,17 @@ export function SettingsGeneral() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const { autostart, setAutostartEnabled } = useAutostartSetting();
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
const {
mode,
setMode,
setUrl,
displayUrl,
showError,
canSave,
save,
checking,
unreachable,
} = useManagementUrl();
const inputRef = useRef<HTMLInputElement>(null);
const prevMode = useRef(mode);
@@ -79,11 +89,17 @@ export function SettingsGeneral() {
? t("settings.general.management.urlError")
: undefined
}
warning={
unreachable
? t("settings.general.management.urlUnreachable")
: undefined
}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
loading={checking}
onClick={() => save()}
>
{t("common.save")}

View File

@@ -46,8 +46,8 @@ export function SettingsNetwork() {
<FancyToggleSwitch
value={!config.disableIpv6}
onChange={(v) => setField("disableIpv6", !v)}
label={"Enable IPv6"}
helpText={"Use IPv6 addressing for the NetBird overlay network."}
label={t("settings.network.ipv6.label")}
helpText={t("settings.network.ipv6.help")}
/>
</SectionGroup>
</>

View File

@@ -24,7 +24,7 @@ export const SectionGroup = ({
// scrollable content above doesn't end up hidden behind the bar.
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[4.1rem] shrink-0"} aria-hidden />
<div className={"h-[4rem] shrink-0"} aria-hidden />
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={

View File

@@ -0,0 +1,193 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Preferences,
Profiles as ProfilesSvc,
Settings as SettingsSvc,
WindowManager,
} from "@bindings/services";
import { SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog } from "@/lib/dialogs";
import { formatErrorMessage } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
import { WelcomeStepManagement } from "./WelcomeStepManagement";
const WINDOW_WIDTH = 360;
// WelcomeStep is the orchestrator's state machine. The transitions:
// tray → management (if eligible) → finish
// tray → finish (otherwise)
// Login itself is no longer part of onboarding — once the welcome window
// closes the user lands in the main window and clicks Connect there.
type WelcomeStep = "tray" | "management";
// shouldShowManagementStep asks the user about Cloud vs self-hosted only
// on a pristine setup — default profile, no email recorded (no successful
// login yet), and the management URL is either unset or already the cloud
// default. Any other state means the user (or a previous run) already
// made a deliberate choice and we shouldn't second-guess it.
function shouldShowManagementStep(
activeProfile: string,
email: string,
managementUrl: string,
): boolean {
if (activeProfile !== "default") return false;
if (email.trim() !== "") return false;
return isCloudManagementUrl(managementUrl);
}
// initial flow snapshot resolved at mount. Held in component state so the
// step-2 management input can hydrate from initialUrl, and so the
// "should we even show step 2" check is computed once (the user can't
// change profile / URL from inside the welcome window).
type InitialState = {
profileName: string;
username: string;
managementUrl: string;
needsManagementStep: boolean;
};
export default function WelcomeDialog() {
const [step, setStep] = useState<WelcomeStep>("tray");
const [initial, setInitial] = useState<InitialState | null>(null);
const [closing, setClosing] = useState(false);
// ready=false until the daemon probe resolves — keeps the window
// Hidden so neither the empty padding-only frame (Linux/GNOME paints
// through) nor a placeholder div leaks onto screen.
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
// Probe daemon state on mount: who's the active profile, do they
// have an email recorded, and what management URL is configured?
// Errors fall through to "skip the management step" so a daemon
// hiccup never blocks onboarding entirely.
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Resolve username + active profile first so GetConfig + List
// can target the actual profile (passing empty strings would
// work today since the daemon falls back to the default
// profile, but being explicit shields us from future
// changes to that fallback).
const [username, active] = await Promise.all([
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
]);
const profileName = active.profileName || "default";
const [config, list] = await Promise.all([
SettingsSvc.GetConfig({ profileName, username }),
ProfilesSvc.List(username),
]);
const profile = list.find((p) => p.name === profileName);
const email = profile?.email ?? "";
if (cancelled) return;
setInitial({
profileName,
username,
managementUrl: config.managementUrl,
needsManagementStep: shouldShowManagementStep(
profileName,
email,
config.managementUrl,
),
});
} catch (e) {
console.error("welcome: initial probe failed", e);
if (cancelled) return;
// Conservative fallback: skip the management step rather
// than block onboarding behind a daemon hiccup.
setInitial({
profileName: "default",
username: "",
managementUrl: "",
needsManagementStep: false,
});
}
})();
return () => {
cancelled = true;
};
}, []);
// finish persists the onboarding flag, opens the main window so the
// user has somewhere to land, and closes the welcome window. Called
// at the end of every successful flow (tray-only and tray→management
// alike). The Connect button in the main window picks up from here.
const finish = useCallback(async () => {
if (closing) return;
setClosing(true);
try {
await Preferences.SetOnboardingCompleted(true);
} catch (e) {
console.error("persist onboarding flag:", e);
}
try {
await WindowManager.OpenMain();
} catch (e) {
console.error("open main window:", e);
}
try {
await WindowManager.CloseWelcome();
} catch (e) {
console.error("close welcome window:", e);
}
}, [closing]);
const handleTrayContinue = useCallback(async () => {
if (initial?.needsManagementStep) {
setStep("management");
} else {
await finish();
}
}, [initial, finish]);
const handleManagementContinue = useCallback(
async (url: string) => {
if (!initial) return;
try {
// SetConfig is a partial update — pointer fields left
// undefined are preserved (services/settings.go). We only
// touch managementUrl; adminUrl stays empty here because
// the daemon already has its own value loaded.
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: initial.profileName,
username: initial.username,
managementUrl: url,
}),
);
} catch (e) {
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
throw e;
}
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
await finish();
},
[initial, finish],
);
const content = useMemo(() => {
if (!initial) {
return null;
}
switch (step) {
case "tray":
return <WelcomeStepTray onContinue={handleTrayContinue} />;
case "management":
return (
<WelcomeStepManagement
initialUrl={initial.managementUrl}
onContinue={handleManagementContinue}
/>
);
}
}, [initial, step, handleTrayContinue, handleManagementContinue]);
return <ConfirmDialog ref={contentRef}>{content}</ConfirmDialog>;
}

View File

@@ -0,0 +1,141 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { Input } from "@/components/inputs/Input";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isCloudManagementUrl,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type WelcomeStepManagementProps = {
// initialUrl is the management URL the daemon is already configured
// with (empty / cloud-default both render as Cloud selected).
initialUrl: string;
// onContinue is invoked with the URL the user wants to persist. The
// parent owns the actual Settings.SetConfig call so the dialog stays
// free of context dependencies.
onContinue: (url: string) => Promise<void>;
};
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
const { t } = useTranslation();
const startsCloud = isCloudManagementUrl(initialUrl);
const [mode, setMode] = useState<ManagementMode>(
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
);
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
const [syntaxError, setSyntaxError] = useState<string | null>(null);
// unreachable: soft warning. Continue stays enabled — user can confirm
// they typed it right and proceed (matches self-hosted-behind-internal-
// DNS / VPN scenarios where the in-app fetch would false-negative).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const trimmedUrl = url.trim();
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
// Continue is no longer disabled for an empty / invalid self-hosted
// URL; a Continue click in that state focuses the input and renders
// an inline error so the user actively notices what's missing.
const inputRef = useRef<HTMLInputElement | null>(null);
// Reset inline error/warning whenever the user edits the URL or flips
// mode — otherwise the warning lingers next to a just-corrected value.
useEffect(() => {
setSyntaxError(null);
setUnreachable(false);
}, [url, mode]);
const handleContinue = useCallback(async () => {
if (checking) return;
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
// Empty or syntactically invalid URL — Continue stays enabled
// so the click registers; surface the error inline and focus
// the input so the user has somewhere to fix it.
setSyntaxError(t("welcome.management.urlInvalid"));
inputRef.current?.focus();
return;
}
const target =
mode === ManagementMode.Cloud
? CLOUD_MANAGEMENT_URL
: normalizeManagementUrl(trimmedUrl);
if (mode === ManagementMode.SelfHosted) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: show soft warning + bail. A second click
// with the same URL skips the check (unreachable still true)
// so the user can proceed if they're sure.
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
}
try {
await onContinue(target);
} catch (e) {
// Parent surfaces save errors via errorDialog; keep a console
// breadcrumb but don't double-render.
console.error("save management url:", e);
}
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
// Syntax problems are hard errors (red); an unreachable-but-valid URL is
// a soft, non-blocking caveat (orange).
const inputError = syntaxError ?? undefined;
const inputWarning = useMemo(
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
[syntaxError, unreachable, t],
);
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading align={"left"}>{t("welcome.management.title")}</DialogHeading>
<DialogDescription align={"left"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
<div className={"wails-no-draggable w-full"}>
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<Input
ref={inputRef}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={inputError}
warning={inputWarning}
autoFocus
/>
</div>
)}
<DialogActions>
<Button
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={handleContinue}
disabled={checking}
>
{checking ? t("welcome.management.checking") : t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { isMacOS, isWindows } from "@/lib/platform";
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
// trayScreenshotForOS picks the marketing screenshot that shows the
// NetBird tray icon in its native menu/task bar — so the onboarding pitch
// matches the chrome the user will actually be hunting for. Evaluated
// inside the component so initPlatform() has finished by the time
// isMacOS/isWindows run (the static imports above only load the bytes,
// no platform check).
function trayScreenshotForOS(): string {
if (isMacOS()) return trayScreenshotDarwin;
if (isWindows()) return trayScreenshotWindows;
return trayScreenshotLinux;
}
type WelcomeStepTrayProps = {
onContinue: () => void;
};
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
return (
<>
<div className={"px-1.5"}>
<img
src={trayScreenshot}
alt={""}
className={"w-full h-auto select-none pointer-events-none rounded-2xl"}
draggable={false}
/>
</div>
<div className={"flex flex-col w-full gap-1"}>
<DialogHeading align={"left"}>{t("welcome.title")}</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
tabIndex={0}
className={"w-full"}
onClick={onContinue}
>
{t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}

View File

@@ -1,6 +1,6 @@
{
"languages": [
{"code": "en", "displayName": "English", "englishName": "English"},
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
]

View File

@@ -61,6 +61,10 @@
"common.saveChanges": "Änderungen speichern",
"common.saving": "Speichert…",
"common.close": "Schließen",
"common.copy": "Kopieren",
"common.togglePasswordVisibility": "Passwortsichtbarkeit umschalten",
"common.increase": "Erhöhen",
"common.decrease": "Verringern",
"common.delete": "Löschen",
"common.create": "Erstellen",
"common.add": "Hinzufügen",
@@ -95,6 +99,10 @@
"header.openSettings": "Einstellungen öffnen",
"header.togglePanel": "Seitenleiste umschalten",
"header.menu.settings": "Einstellungen …",
"header.menu.defaultView": "Standardansicht",
"header.menu.advancedView": "Erweiterte Ansicht",
"header.menu.updateAvailable": "Update verfügbar",
"profile.selector.loading": "Lädt…",
"profile.selector.noProfile": "Kein Profil",
@@ -105,16 +113,33 @@
"profile.selector.moreOptions": "Weitere Optionen",
"profile.selector.deregister": "Abmelden",
"profile.selector.delete": "Profil löschen",
"profile.selector.switchTo": "Zu diesem Profil wechseln",
"profile.dropdown.activeProfile": "Aktives Profil",
"profile.dropdown.switchProfile": "Profil wechseln",
"profile.dropdown.noEmail": "Andere",
"profile.dropdown.addProfile": "Profil hinzufügen",
"profile.dropdown.manageProfiles": "Profile verwalten",
"profile.dropdown.settings": "Einstellungen",
"profile.dialog.title": "Neues Profil",
"profile.dialog.description": "Mit Profilen können Sie mehrere NetBird-Verbindungen nebeneinander verwalten. Geben Sie Ihrem Profil einen aussagekräftigen Namen.",
"profile.dialog.nameLabel": "Profilname",
"profile.dialog.description": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest.",
"profile.dialog.placeholder": "z. B. Arbeit",
"profile.dialog.required": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat",
"profile.dialog.submit": "Profil hinzufügen",
"profile.dialog.managementHelp": "NetBird Cloud oder Ihr eigener Server.",
"profile.dialog.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist.",
"profile.deregister.title": "Profil abmelden",
"profile.deregister.message": "Sind Sie sicher, dass Sie \"{name}\" abmelden möchten? Sie müssen sich erneut anmelden, um es zu nutzen.",
"profile.switch.title": "Zu Profil \"{name}\" wechseln?",
"profile.switch.message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt.",
"profile.switch.confirm": "Bestätigen",
"profile.deregister.title": "Profil \"{name}\" abmelden?",
"profile.deregister.message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen.",
"profile.deregister.confirm": "Abmelden",
"profile.delete.title": "Profil löschen",
"profile.delete.message": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"profile.delete.title": "Profil \"{name}\" löschen?",
"profile.delete.message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden.",
"profile.delete.disabledActive": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen.",
"profile.delete.disabledDefault": "Das Standardprofil kann nicht gelöscht werden.",
"profile.error.switchTitle": "Profilwechsel fehlgeschlagen",
"profile.error.deregisterTitle": "Abmeldung fehlgeschlagen",
"profile.error.deleteTitle": "Löschen des Profils fehlgeschlagen",
@@ -129,11 +154,19 @@
"settings.tabs.network": "Netzwerk",
"settings.tabs.security": "Sicherheit",
"settings.tabs.ssh": "SSH",
"settings.tabs.profiles": "Profile",
"settings.tabs.advanced": "Erweitert",
"settings.tabs.troubleshooting": "Fehlerbehebung",
"settings.tabs.about": "Über",
"settings.tabs.updateAvailable": "Update verfügbar",
"settings.profiles.section.profiles": "Profile",
"settings.profiles.intro": "Halten Sie separate NetBird-Identitäten nebeneinander, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie.",
"settings.profiles.addProfile": "Profil hinzufügen",
"settings.profiles.active": "Aktiv",
"settings.profiles.emptyTitle": "Keine Profile",
"settings.profiles.emptyDescription": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden.",
"settings.general.section.general": "Allgemein",
"settings.general.section.connection": "Verbindung",
"settings.general.connectOnStartup.label": "Beim Start verbinden",
@@ -153,8 +186,9 @@
"settings.general.management.selfHosted": "Self-hosted",
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
"settings.general.management.urlError": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
"settings.general.management.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist.",
"settings.general.management.switchCloudTitle": "Zu NetBird Cloud wechseln?",
"settings.general.management.switchCloudMessage": "Dadurch wird die Verbindung zu Ihrem self-hosted Management-Server getrennt und eine neue Verbindung zu NetBird Cloud hergestellt. Möglicherweise müssen Sie sich erneut anmelden.",
"settings.general.management.switchCloudMessage": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden.",
"settings.general.management.switchCloudConfirm": "Zu Cloud wechseln",
"settings.network.section.connectivity": "Konnektivität",
@@ -169,6 +203,8 @@
"settings.network.clientRoutes.help": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen.",
"settings.network.serverRoutes.label": "Server-Routen aktivieren",
"settings.network.serverRoutes.help": "Lokale Routen dieses Hosts an andere Peers ankündigen.",
"settings.network.ipv6.label": "IPv6 aktivieren",
"settings.network.ipv6.help": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden.",
"settings.security.section.firewall": "Firewall",
"settings.security.section.encryption": "Verschlüsselung",
@@ -207,6 +243,7 @@
"settings.advanced.interfaceName.errorMac": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100).",
"settings.advanced.port.label": "Port",
"settings.advanced.port.error": "Gib einen Port zwischen {min} und {max} ein.",
"settings.advanced.port.help": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet.",
"settings.advanced.mtu.label": "MTU",
"settings.advanced.mtu.error": "Gib eine MTU zwischen {min} und {max} ein.",
"settings.advanced.psk.label": "Pre-shared Key",
@@ -308,6 +345,23 @@
"window.title.sessionExpired": "Sitzung abgelaufen",
"window.title.sessionExpiring": "Sitzung läuft ab",
"window.title.updating": "Aktualisierung",
"window.title.welcome": "Willkommen bei NetBird",
"welcome.title": "Suchen Sie NetBird in der Taskleiste",
"welcome.description": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen.",
"welcome.continue": "Weiter",
"welcome.back": "Zurück",
"welcome.management.title": "NetBird einrichten",
"welcome.management.description": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben.",
"welcome.management.cloud.title": "NetBird Cloud",
"welcome.management.cloud.description": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig.",
"welcome.management.selfHosted.title": "Selbst gehostet",
"welcome.management.selfHosted.description": "Verbindung zu Ihrem eigenen Management-Server.",
"welcome.management.urlLabel": "URL des Management-Servers",
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
"welcome.management.urlInvalid": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
"welcome.management.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist.",
"welcome.management.checking": "Wird geprüft …",
"browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort",
"browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?",
@@ -320,10 +374,10 @@
"sessionExpired.signIn": "Anmelden",
"sessionAboutToExpire.title": "Sitzung läuft bald ab",
"sessionAboutToExpire.titleLater": "Sitzung läuft ab",
"sessionAboutToExpire.description": "Ihre NetBird-Sitzung läuft in Kürze ab. Bleiben Sie verbunden, damit Ihre Geräte online bleiben.",
"sessionAboutToExpire.descriptionLater": "Ihre NetBird-Sitzung läuft ab. Verlängern Sie jetzt, damit Ihre Geräte online bleiben.",
"sessionAboutToExpire.stay": "Verbunden bleiben",
"sessionAboutToExpire.titleLater": "Ihre Sitzung läuft ab",
"sessionAboutToExpire.description": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich.",
"sessionAboutToExpire.descriptionLater": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden.",
"sessionAboutToExpire.stay": "Sitzung erneuern",
"sessionAboutToExpire.logout": "Abmelden",
"sessionAboutToExpire.expired": "Sitzung abgelaufen",
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
@@ -355,7 +409,6 @@
"peers.status.disconnected": "Getrennt",
"peers.details.relayAddress": "Relay",
"peers.details.networks": "Ressourcen",
"peers.details.more": "+{count} weitere",
"peers.details.relayed": "Relayed",
"peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass aktiviert",
@@ -368,7 +421,6 @@
"networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
"networks.selected": "Ausgewählt",
"networks.unselected": "Nicht ausgewählt",
"networks.ips.more": "+{count} weitere",
"networks.ips.heading": "Aufgelöste IPs",
"networks.bulk.selectionCount": "{selected} von {total} aktiv",
"networks.bulk.enableAll": "Alle aktivieren",
@@ -378,6 +430,11 @@
"exitNodes.none": "Keiner",
"exitNodes.empty.title": "Keine Exit Nodes verfügbar",
"exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
"exitNodes.card.title": "Exit Node",
"exitNodes.card.statusActive": "Aktiv",
"exitNodes.card.statusInactive": "Inaktiv",
"exitNodes.dropdown.noneTitle": "Keiner",
"exitNodes.dropdown.noneDescription": "Direkte Verbindung ohne Exit Node",
"quickActions.connect": "Verbinden",
"quickActions.disconnect": "Trennen",

View File

@@ -61,6 +61,10 @@
"common.saveChanges": "Save Changes",
"common.saving": "Saving…",
"common.close": "Close",
"common.copy": "Copy",
"common.togglePasswordVisibility": "Toggle password visibility",
"common.increase": "Increase",
"common.decrease": "Decrease",
"common.delete": "Delete",
"common.create": "Create",
"common.add": "Add",
@@ -105,23 +109,32 @@
"profile.selector.moreOptions": "More options",
"profile.selector.deregister": "Deregister",
"profile.selector.delete": "Delete",
"profile.selector.switchTo": "Switch to this profile",
"profile.dialog.title": "Enter Profile Name",
"profile.dialog.description": "Choose a memorable name.",
"profile.dialog.placeholder": "e.g. Work",
"profile.dialog.nameLabel": "Profile Name",
"profile.dialog.description": "Set an easily identifiable name for your profile.",
"profile.dialog.placeholder": "e.g. work",
"profile.dialog.submit": "Add Profile",
"profile.dialog.required": "Please enter a profile name, e.g. Work, Home",
"profile.dialog.required": "Please enter a profile name, e.g. work, home",
"profile.dialog.managementHelp": "Use NetBird Cloud or your own server.",
"profile.dialog.urlUnreachable": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.",
"header.menu.settings": "Settings...",
"header.menu.defaultView": "Default View",
"header.menu.advancedView": "Advanced View",
"header.menu.updateAvailable": "Update Available",
"profile.deregister.title": "Deregister Profile",
"profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.",
"profile.switch.title": "Switch Profile to \"{name}\"?",
"profile.switch.message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.",
"profile.switch.confirm": "Confirm",
"profile.deregister.title": "Deregister Profile \"{name}\"?",
"profile.deregister.message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.",
"profile.deregister.confirm": "Deregister",
"profile.delete.title": "Delete Profile",
"profile.delete.message": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"profile.delete.title": "Delete Profile \"{name}\"?",
"profile.delete.message": "Are you sure you want to delete this profile?\nThis action cannot be undone.",
"profile.delete.disabledActive": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.",
"profile.delete.disabledDefault": "The default profile cannot be deleted.",
"profile.error.switchTitle": "Switch Profile Failed",
"profile.error.deregisterTitle": "Deregister Profile Failed",
"profile.error.deleteTitle": "Delete Profile Failed",
@@ -175,8 +188,9 @@
"settings.general.management.selfHosted": "Self-hosted",
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
"settings.general.management.urlError": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
"settings.general.management.urlUnreachable": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.",
"settings.general.management.switchCloudTitle": "Switch to NetBird Cloud?",
"settings.general.management.switchCloudMessage": "This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
"settings.general.management.switchCloudMessage": "This disconnects your self-hosted server.\nYou may need to log in again.",
"settings.general.management.switchCloudConfirm": "Switch to Cloud",
"settings.network.section.connectivity": "Connectivity",
@@ -191,6 +205,8 @@
"settings.network.clientRoutes.help": "Accept routes from other peers to reach their networks.",
"settings.network.serverRoutes.label": "Enable Server Routes",
"settings.network.serverRoutes.help": "Advertise this host's local routes to other peers.",
"settings.network.ipv6.label": "Enable IPv6",
"settings.network.ipv6.help": "Use IPv6 addressing for the NetBird overlay network.",
"settings.security.section.firewall": "Firewall",
"settings.security.section.encryption": "Encryption",
@@ -225,8 +241,13 @@
"settings.advanced.section.interface": "Interface",
"settings.advanced.section.security": "Security",
"settings.advanced.interfaceName.label": "Name",
"settings.advanced.interfaceName.error": "Use 1-15 letters, digits, dots, hyphens, or underscores.",
"settings.advanced.interfaceName.errorMac": "Must start with \"utun\" followed by a number (e.g. utun100).",
"settings.advanced.port.label": "Port",
"settings.advanced.port.error": "Enter a port between {min} and {max}.",
"settings.advanced.port.help": "If set to 0, a random free port will be used.",
"settings.advanced.mtu.label": "MTU",
"settings.advanced.mtu.error": "Enter an MTU value between {min} and {max}.",
"settings.advanced.psk.label": "Pre-shared Key",
"settings.advanced.psk.help": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.",
@@ -245,7 +266,7 @@
"settings.troubleshooting.duration.suffix": "Minute(s)",
"settings.troubleshooting.create": "Create Bundle",
"settings.troubleshooting.progress.description": "Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes.",
"settings.troubleshooting.cancelling": "Cancelling…",
"settings.troubleshooting.cancelling": "Canceling…",
"settings.troubleshooting.done.uploadedTitle": "Debug bundle successfully uploaded!",
"settings.troubleshooting.done.savedTitle": "Bundle saved",
"settings.troubleshooting.done.uploadedDescription": "Share the upload key below with NetBird support. A local copy was also saved on your device.",
@@ -261,7 +282,7 @@
"settings.troubleshooting.stage.restoring": "Restoring previous log level…",
"settings.troubleshooting.stage.bundling": "Generating debug bundle…",
"settings.troubleshooting.stage.uploading": "Uploading to NetBird…",
"settings.troubleshooting.stage.cancelling": "Cancelling…",
"settings.troubleshooting.stage.cancelling": "Canceling…",
"settings.about.client": "NetBird Client v{version}",
"settings.about.clientName": "NetBird Client",
@@ -326,10 +347,28 @@
"window.title.sessionExpired": "Session Expired",
"window.title.sessionExpiring": "Session Expiring",
"window.title.updating": "Updating",
"window.title.welcome": "Welcome to NetBird",
"welcome.title": "Look for NetBird in your tray",
"welcome.description": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
"welcome.continue": "Continue",
"welcome.back": "Back",
"welcome.management.title": "Set up NetBird",
"welcome.management.description": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.",
"welcome.management.cloud.title": "NetBird Cloud",
"welcome.management.cloud.description": "Use our hosted service. No setup required.",
"welcome.management.selfHosted.title": "Self-hosted",
"welcome.management.selfHosted.description": "Connect to your own management server.",
"welcome.management.urlLabel": "Management server URL",
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
"welcome.management.urlInvalid": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
"welcome.management.urlUnreachable": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.",
"welcome.management.checking": "Checking…",
"browserLogin.title": "Continue in your browser to complete the login",
"browserLogin.notSeeing": "Not seeing the browser tab?",
"browserLogin.tryAgain": "Try again",
"browserLogin.openFailedTitle": "Open Browser Failed",
"sessionExpired.title": "Session expired",
"sessionExpired.description": "Your NetBird session has expired. Sign in again to keep your devices connected.",
@@ -337,13 +376,14 @@
"sessionExpired.signIn": "Sign in",
"sessionAboutToExpire.title": "Session expiring soon",
"sessionAboutToExpire.titleLater": "Session will expire",
"sessionAboutToExpire.description": "Your NetBird session will expire shortly. Stay connected to keep your devices online.",
"sessionAboutToExpire.descriptionLater": "Your NetBird session will expire. Extend now to keep your devices online.",
"sessionAboutToExpire.stay": "Stay connected",
"sessionAboutToExpire.titleLater": "Your session will expire",
"sessionAboutToExpire.description": "This device will disconnect soon. Renew with a browser sign-in.",
"sessionAboutToExpire.descriptionLater": "A browser sign-in keeps this device connected to your network.",
"sessionAboutToExpire.stay": "Renew session",
"sessionAboutToExpire.logout": "Logout",
"sessionAboutToExpire.expired": "Session expired",
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
"sessionAboutToExpire.logoutFailedTitle": "Logout Failed",
"peers.search.placeholder": "Search by name or IP",
"peers.filter.all": "All",
@@ -371,7 +411,6 @@
"peers.status.disconnected": "Disconnected",
"peers.details.relayAddress": "Relay",
"peers.details.networks": "Resources",
"peers.details.more": "+{count} more",
"peers.details.relayed": "Relayed",
"peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass enabled",
@@ -384,7 +423,6 @@
"networks.empty.description": "No routed networks have been shared with this peer.",
"networks.selected": "Selected",
"networks.unselected": "Not selected",
"networks.ips.more": "+{count} more",
"networks.ips.heading": "Resolved IPs",
"networks.bulk.selectionCount": "{selected} of {total} Active",
"networks.bulk.enableAll": "Enable all",
@@ -394,6 +432,11 @@
"exitNodes.none": "None",
"exitNodes.empty.title": "No exit nodes available",
"exitNodes.empty.description": "No exit nodes have been shared with this peer.",
"exitNodes.card.title": "Exit Node",
"exitNodes.card.statusActive": "Active",
"exitNodes.card.statusInactive": "Inactive",
"exitNodes.dropdown.noneTitle": "None",
"exitNodes.dropdown.noneDescription": "Direct connection without an exit node",
"quickActions.connect": "Connect",
"quickActions.disconnect": "Disconnect",

View File

@@ -61,6 +61,10 @@
"common.saveChanges": "Módosítások mentése",
"common.saving": "Mentés…",
"common.close": "Bezárás",
"common.copy": "Másolás",
"common.togglePasswordVisibility": "Jelszó láthatóságának váltása",
"common.increase": "Növelés",
"common.decrease": "Csökkentés",
"common.delete": "Törlés",
"common.create": "Létrehozás",
"common.add": "Hozzáadás",
@@ -95,6 +99,10 @@
"header.openSettings": "Beállítások megnyitása",
"header.togglePanel": "Oldalsó panel váltása",
"header.menu.settings": "Beállítások…",
"header.menu.defaultView": "Alapnézet",
"header.menu.advancedView": "Speciális nézet",
"header.menu.updateAvailable": "Frissítés elérhető",
"profile.selector.loading": "Betöltés…",
"profile.selector.noProfile": "Nincs profil",
@@ -105,16 +113,33 @@
"profile.selector.moreOptions": "További műveletek",
"profile.selector.deregister": "Leválasztás",
"profile.selector.delete": "Profil törlése",
"profile.selector.switchTo": "Váltás erre a profilra",
"profile.dropdown.activeProfile": "Aktív profil",
"profile.dropdown.switchProfile": "Profilváltás",
"profile.dropdown.noEmail": "Egyéb",
"profile.dropdown.addProfile": "Profil hozzáadása",
"profile.dropdown.manageProfiles": "Profilok kezelése",
"profile.dropdown.settings": "Beállítások",
"profile.dialog.title": "Új profil",
"profile.dialog.description": "A profilok lehetővé teszik, hogy különálló NetBird-kapcsolatokat tartson egymás mellett. Adjon profiljának egy könnyen megjegyezhető nevet.",
"profile.dialog.nameLabel": "Profilnév",
"profile.dialog.description": "Adjon profiljának egy könnyen azonosítható nevet.",
"profile.dialog.placeholder": "pl. Munka",
"profile.dialog.required": "Adjon meg egy profilnevet, pl. Munka, Otthon",
"profile.dialog.submit": "Profil hozzáadása",
"profile.dialog.managementHelp": "NetBird Cloud vagy saját kiszolgáló.",
"profile.dialog.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes.",
"profile.deregister.title": "Profil leválasztása",
"profile.deregister.message": "Biztosan le szeretné választani a következőt: \"{name}\"? Újra be kell jelentkeznie a használatához.",
"profile.switch.title": "Váltás a(z) \"{name}\" profilra?",
"profile.switch.message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva.",
"profile.switch.confirm": "Megerősítés",
"profile.deregister.title": "\"{name}\" profil leválasztása?",
"profile.deregister.message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához.",
"profile.deregister.confirm": "Leválasztás",
"profile.delete.title": "Profil törlése",
"profile.delete.message": "Biztosan törölni szeretné a következőt: \"{name}\"? Ez a művelet nem vonható vissza.",
"profile.delete.title": "\"{name}\" profil törlése?",
"profile.delete.message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza.",
"profile.delete.disabledActive": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt.",
"profile.delete.disabledDefault": "Az alapértelmezett profil nem törölhető.",
"profile.error.switchTitle": "Profilváltás sikertelen",
"profile.error.deregisterTitle": "Leválasztás sikertelen",
"profile.error.deleteTitle": "Profil törlése sikertelen",
@@ -129,11 +154,19 @@
"settings.tabs.network": "Hálózat",
"settings.tabs.security": "Biztonság",
"settings.tabs.ssh": "SSH",
"settings.tabs.profiles": "Profilok",
"settings.tabs.advanced": "Speciális",
"settings.tabs.troubleshooting": "Hibaelhárítás",
"settings.tabs.about": "Névjegy",
"settings.tabs.updateAvailable": "Frissítés elérhető",
"settings.profiles.section.profiles": "Profilok",
"settings.profiles.intro": "Tartson külön NetBird-identitásokat egymás mellett, például munkahelyi és személyes fiókokat, vagy különböző kezelőszervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat.",
"settings.profiles.addProfile": "Profil hozzáadása",
"settings.profiles.active": "Aktív",
"settings.profiles.emptyTitle": "Nincsenek profilok",
"settings.profiles.emptyDescription": "Hozzon létre egy profilt a NetBird kezelőszerverhez való csatlakozáshoz.",
"settings.general.section.general": "Általános",
"settings.general.section.connection": "Kapcsolat",
"settings.general.connectOnStartup.label": "Csatlakozás indításkor",
@@ -153,8 +186,9 @@
"settings.general.management.selfHosted": "Saját üzemeltetésű",
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
"settings.general.management.urlError": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
"settings.general.management.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes.",
"settings.general.management.switchCloudTitle": "Átváltás a NetBird Cloudra?",
"settings.general.management.switchCloudMessage": "Ez megszünteti a kapcsolatot a saját üzemeltetésű kezelőszerverrel, és újra csatlakozik a NetBird Cloudhoz. Lehet, hogy újra be kell jelentkeznie.",
"settings.general.management.switchCloudMessage": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie.",
"settings.general.management.switchCloudConfirm": "Váltás a felhőre",
"settings.network.section.connectivity": "Kapcsolódás",
@@ -169,6 +203,8 @@
"settings.network.clientRoutes.help": "Más társak útvonalainak elfogadása az ő hálózataik eléréséhez.",
"settings.network.serverRoutes.label": "Szerver útvonalak engedélyezése",
"settings.network.serverRoutes.help": "Ennek a gazdának a helyi útvonalainak meghirdetése más társak számára.",
"settings.network.ipv6.label": "IPv6 engedélyezése",
"settings.network.ipv6.help": "IPv6-címzés használata a NetBird overlay hálózathoz.",
"settings.security.section.firewall": "Tűzfal",
"settings.security.section.encryption": "Titkosítás",
@@ -207,6 +243,7 @@
"settings.advanced.interfaceName.errorMac": "„utun” után számmal kezdődjön (pl. utun100).",
"settings.advanced.port.label": "Port",
"settings.advanced.port.error": "Adj meg egy portot {min} és {max} között.",
"settings.advanced.port.help": "Ha 0-ra állítod, egy véletlenszerű szabad portot használ.",
"settings.advanced.mtu.label": "MTU",
"settings.advanced.mtu.error": "Adj meg egy MTU értéket {min} és {max} között.",
"settings.advanced.psk.label": "Pre-shared kulcs",
@@ -308,6 +345,23 @@
"window.title.sessionExpired": "Munkamenet lejárt",
"window.title.sessionExpiring": "Munkamenet lejár",
"window.title.updating": "Frissítés",
"window.title.welcome": "Üdvözli a NetBird",
"welcome.title": "Keresse a NetBirdöt a tálcán",
"welcome.description": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához.",
"welcome.continue": "Folytatás",
"welcome.back": "Vissza",
"welcome.management.title": "NetBird beállítása",
"welcome.management.description": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van.",
"welcome.management.cloud.title": "NetBird Cloud",
"welcome.management.cloud.description": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra.",
"welcome.management.selfHosted.title": "Saját üzemeltetésű",
"welcome.management.selfHosted.description": "Csatlakozás a saját menedzsmentszerveréhez.",
"welcome.management.urlLabel": "Menedzsmentszerver URL",
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
"welcome.management.urlInvalid": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
"welcome.management.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes.",
"welcome.management.checking": "Ellenőrzés…",
"browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez",
"browserLogin.notSeeing": "Nem látja a böngésző fülét?",
@@ -320,10 +374,10 @@
"sessionExpired.signIn": "Bejelentkezés",
"sessionAboutToExpire.title": "A munkamenet hamarosan lejár",
"sessionAboutToExpire.titleLater": "A munkamenet lejár",
"sessionAboutToExpire.description": "A NetBird munkamenete hamarosan lejár. Maradjon csatlakoztatva, hogy az eszközei elérhetők maradjanak.",
"sessionAboutToExpire.descriptionLater": "A NetBird munkamenete lejár. Hosszabbítsa meg most, hogy az eszközei elérhetők maradjanak.",
"sessionAboutToExpire.stay": "Maradjon csatlakoztatva",
"sessionAboutToExpire.titleLater": "A munkamenete lejár",
"sessionAboutToExpire.description": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell.",
"sessionAboutToExpire.descriptionLater": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt.",
"sessionAboutToExpire.stay": "Munkamenet megújítása",
"sessionAboutToExpire.logout": "Kijelentkezés",
"sessionAboutToExpire.expired": "Munkamenet lejárt",
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
@@ -355,7 +409,6 @@
"peers.status.disconnected": "Lecsatlakozva",
"peers.details.relayAddress": "Relay",
"peers.details.networks": "Erőforrások",
"peers.details.more": "+{count} további",
"peers.details.relayed": "Relayed",
"peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass engedélyezve",
@@ -368,7 +421,6 @@
"networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
"networks.selected": "Kiválasztva",
"networks.unselected": "Nincs kiválasztva",
"networks.ips.more": "+{count} további",
"networks.ips.heading": "Feloldott IP-címek",
"networks.bulk.selectionCount": "{selected} / {total} aktív",
"networks.bulk.enableAll": "Összes engedélyezése",
@@ -378,6 +430,11 @@
"exitNodes.none": "Egyik sem",
"exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
"exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
"exitNodes.card.title": "Kilépő csomópont",
"exitNodes.card.statusActive": "Aktív",
"exitNodes.card.statusInactive": "Inaktív",
"exitNodes.dropdown.noneTitle": "Egyik sem",
"exitNodes.dropdown.noneDescription": "Közvetlen kapcsolat kilépő csomópont nélkül",
"quickActions.connect": "Csatlakozás",
"quickActions.disconnect": "Bontás",

View File

@@ -148,10 +148,26 @@ func main() {
// (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close
// so they don't linger as hidden windows that Wails's macOS dock-reopen
// handler would pop back up.
windowManager := services.NewWindowManager(app, window, bundle, prefStore)
windowManager.WatchLanguage(prefStore)
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
// On minimal WMs (the in-process XEmbed-tray path) the WM neither centers
// small windows nor restores their position across a hide -> show, so the
// main/Settings windows would open in the top-left corner. Gate Go-side
// re-centering on that environment; nil (full desktops, macOS, Windows)
// leaves placement to the WM. See WindowManager.SetRecenterOnShow.
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
app.RegisterService(application.NewService(windowManager))
// Welcome / onboarding window. First launch only — the Continue
// button in the dialog flips OnboardingCompleted=true via the
// Preferences service before closing, so subsequent launches skip
// straight to the tray-only flow. ApplicationStarted hook so the
// Wails window machinery is fully up before the window is created.
if !prefStore.Get().OnboardingCompleted {
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
windowManager.OpenWelcome()
})
}
// Register an in-process StatusNotifierWatcher so the tray works on
// minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the
// AppIndicator extension) that don't ship one themselves. No-op on
@@ -313,10 +329,14 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
initialWidth = 900
}
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "NetBird",
Width: initialWidth,
Height: 640,
Name: "main",
Title: "NetBird",
Width: initialWidth,
Height: services.WindowHeight,
// Center on first show. Full DEs (GNOME/KDE) place small windows
// centered by default, but minimal WMs (fluxbox et al, the XEmbed
// tray path) drop new windows in the top-left corner unless asked.
InitialPosition: application.WindowCentered,
Hidden: true,
BackgroundColour: services.WindowBackgroundColour,
URL: "/",

View File

@@ -68,8 +68,9 @@ func (v ViewMode) IsValid() bool {
// frontend. Pointer-free because the whole document is rewritten on every
// change — there are no per-field partial updates.
type UIPreferences struct {
Language i18n.LanguageCode `json:"language"`
ViewMode ViewMode `json:"viewMode"`
Language i18n.LanguageCode `json:"language"`
ViewMode ViewMode `json:"viewMode"`
OnboardingCompleted bool `json:"onboardingCompleted"`
}
// LanguageValidator is the dependency Store needs to reject SetLanguage
@@ -162,6 +163,28 @@ func (s *Store) SetViewMode(mode ViewMode) error {
return nil
}
// SetOnboardingCompleted persists the welcome-window dismissal so the
// welcome flow doesn't run again on subsequent launches. Idempotent — a
// repeat of the current value is a no-op (no disk write, no broadcast).
func (s *Store) SetOnboardingCompleted(done bool) error {
s.mu.Lock()
if s.current.OnboardingCompleted == done {
s.mu.Unlock()
return nil
}
next := s.current
next.OnboardingCompleted = done
if err := s.persistLocked(next); err != nil {
s.mu.Unlock()
return fmt.Errorf("persist preferences: %w", err)
}
s.current = next
s.mu.Unlock()
s.broadcast(next)
return nil
}
// SetLanguage validates and persists a new language preference, then
// broadcasts the change to internal subscribers (tray) and the emitter
// (frontend).

View File

@@ -0,0 +1,17 @@
//go:build linux && !(linux && 386)
package main
// recenterOnShowPredicate returns the predicate WindowManager uses to decide
// whether to re-center its Go-shown windows (main, Settings) on each show.
//
// On Linux this is xembedTrayAvailable: re-centering is needed only in the
// minimal-WM / in-process-XEmbed-tray environment, where the window manager
// neither centers small windows for us nor restores their position across a
// hide -> show round-trip. The predicate is evaluated per show (not once at
// startup) because the XEmbed tray can appear after the UI starts — the panel
// and the autostarted app race at login — and xembedTrayAvailable is a cheap,
// side-effect-free selection-owner probe, fine to call repeatedly.
func recenterOnShowPredicate() func() bool {
return xembedTrayAvailable
}

View File

@@ -0,0 +1,12 @@
//go:build !linux || (linux && 386)
package main
// recenterOnShowPredicate returns nil off Linux (and on the cgo-less linux/386
// build): macOS and Windows window managers center windows and restore their
// position across hide -> show themselves, so the Go-side re-centering that
// the minimal-WM Linux path needs would only fight a window the user moved.
// A nil predicate makes WindowManager.centerWhenReady a no-op.
func recenterOnShowPredicate() func() bool {
return nil
}

View File

@@ -12,8 +12,10 @@ import (
"runtime"
"strings"
log "github.com/sirupsen/logrus"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
@@ -339,5 +341,17 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
if _, err = cli.Logout(ctx, req); err != nil {
return s.classifyDaemonError(err)
}
// The daemon runs as root and can't reach the user-owned per-profile state
// file that holds the account email (see Profiles.List). Drop it here from
// the UI process so a logged-out profile no longer shows a stale email; the
// next SSO login recreates it.
if p.ProfileName != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
// Non-fatal: the logout itself succeeded.
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
}
}
return nil
}

View File

@@ -46,6 +46,21 @@ const (
// tray (Go side) so the frontend stays passive on this flow.
EventSessionWarning = "netbird:session:warning"
// MetadataKindProfileListChanged is the SystemEvent.metadata["kind"]
// marker the daemon stamps on the INFO/SYSTEM event it publishes after a
// CLI-driven AddProfile / RemoveProfile (the daemon emits no dedicated
// profile RPC event). dispatchSystemEvent recognises it and re-emits the
// existing EventProfileChanged so the tray and React profile views refresh
// — closing the gap the SubscribeStatus path can't, since a profile
// add/remove doesn't change the daemon's status string (the tray's
// iconChanged guard would swallow it). The daemon side hard-codes the same
// string literal in client/server/server.go (client/server cannot import
// this UI package).
MetadataKindProfileListChanged = "profile-list-changed"
// metadataKindKey is the SystemEvent.metadata key the "kind" marker lives
// under. Kept in sync with the daemon-side literal in client/server.
metadataKindKey = "kind"
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
// daemon's gRPC socket is unreachable (daemon not running, socket
// permission, etc.). Real daemon statuses come straight from
@@ -526,6 +541,16 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error {
func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
se := systemEventFromProto(ev)
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
// A CLI-driven profile add/remove publishes a marked SYSTEM event purely
// to nudge the UI's profile views. Translate it into the existing
// EventProfileChanged (which the tray's loadProfiles and React's
// ProfileContext.refresh already subscribe to) and stop — it's an internal
// refresh signal, not a user-facing notification, so it must not reach the
// Recent Events list or fire an OS toast.
if se.Metadata[metadataKindKey] == MetadataKindProfileListChanged {
s.emitter.Emit(EventProfileChanged, ProfileRef{})
return
}
s.emitter.Emit(EventDaemonNotification, se)
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
s.emitter.Emit(EventSessionWarning, warn)

View File

@@ -10,6 +10,7 @@ import (
"runtime"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundleParams configures what the daemon collects when generating a
@@ -55,6 +56,7 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
SystemInfo: p.SystemInfo,
UploadURL: p.UploadURL,
LogFileCount: p.LogFileCount,
CliVersion: version.NetbirdVersion(),
})
if err != nil {
return DebugBundleResult{}, err

View File

@@ -36,3 +36,8 @@ func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) err
func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error {
return s.store.SetViewMode(mode)
}
// SetOnboardingCompleted persists the welcome-flow dismissal flag.
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
return s.store.SetOnboardingCompleted(done)
}

View File

@@ -6,6 +6,7 @@ import (
"net/url"
"strconv"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -45,6 +46,10 @@ const EventSettingsOpen = "netbird:settings:open"
// at #181A1D / nb-gray-950, used by AppLayout's <html> background).
var WindowBackgroundColour = application.NewRGB(24, 26, 29)
// WindowHeight is the shared frame height for the main window and the
// Settings window so the right panel inside both ends up the same size.
const WindowHeight = 660
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
// Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21);
// title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only
@@ -88,6 +93,17 @@ func AppleMacOSAppearanceOptions() application.MacWindow {
}
}
// LinuxAppearanceOptions is the per-window Linux chrome shared by every
// NetBird webview window. Icon shows up in the WM task list / minimised
// state; WindowIsTranslucent is left off so the opaque background colour
// paints reliably on compositors that fake translucency badly.
func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
return application.LinuxWindow{
Icon: icon,
WindowIsTranslucent: false,
}
}
// DialogWindowOptions is the baseline for every auxiliary dialog window
// (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress).
// All four share size (360x320), the no-resize / no-min / no-max chrome,
@@ -96,7 +112,7 @@ func AppleMacOSAppearanceOptions() application.MacWindow {
// this), and the shared background/Mac/Windows appearance. Callers fill
// in per-dialog overrides (URL params, screen targeting, etc.) on the
// returned value before passing it to Window.NewWithOptions.
func DialogWindowOptions(name, title, url string) application.WebviewWindowOptions {
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
return application.WebviewWindowOptions{
Name: name,
Title: title,
@@ -112,6 +128,7 @@ func DialogWindowOptions(name, title, url string) application.WebviewWindowOptio
URL: url,
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
Linux: LinuxAppearanceOptions(linuxIcon),
}
}
@@ -132,17 +149,30 @@ type WindowManager struct {
mainWindow *application.WebviewWindow
translator ErrorTranslator
prefs LanguagePreference
linuxIcon []byte
settings *application.WebviewWindow
browserLogin *application.WebviewWindow
sessionExpired *application.WebviewWindow
sessionAboutToExpire *application.WebviewWindow
installProgress *application.WebviewWindow
welcome *application.WebviewWindow
// hiddenForLogin remembers windows that were visible when the
// BrowserLogin popup opened. They were Hide()n to keep focus on the
// SSO flow without resorting to AlwaysOnTop, and are restored when
// the BrowserLogin window closes (success or cancel).
hiddenForLogin []application.Window
mu sync.Mutex
// recenterOnShow reports whether Go should re-center the Go-shown
// windows (main, Settings) on each show. Only true in the minimal-WM /
// in-process XEmbed-tray environment, where the WM neither centers small
// windows for us nor restores their position across a hide -> show
// round-trip. On full desktops (GNOME/KDE) the WM handles placement, so
// re-centering is unnecessary and would fight a window the user moved —
// there this stays nil and centerWhenReady is a no-op. Set by the Linux
// startup path via SetRecenterOnShow; nil on macOS/Windows and in tests.
// A predicate (not a bool) because the XEmbed tray can appear after the
// UI starts (panel/app login race), so the answer is evaluated per show.
recenterOnShow func() bool
}
// title resolves a window-title i18n key in the user's current language.
@@ -173,13 +203,33 @@ func (s *WindowManager) title(key string) string {
// The Settings window is created here, hidden, so the first OpenSettings
// call paints instantly instead of paying webview construction + asset load
// at click time.
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference) *WindowManager {
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs}
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
// If the prefs implementation also exposes Subscribe (the runtime
// *preferences.Store does), wire up a goroutine that re-titles every
// live auxiliary window on language flip. Done here — instead of via
// an exported WatchLanguage method on the service — so the Wails
// binding generator doesn't try to expose a LanguageSubscriber-taking
// method to the frontend (interface params can't round-trip through
// JSON and would emit a generator warning).
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
ch, _ := sub.Subscribe()
go func() {
var last i18n.LanguageCode
for p := range ch {
if p.Language == "" || p.Language == last {
continue
}
last = p.Language
s.retitleAll()
}
}()
}
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: s.title("window.title.settings"),
Width: 900,
Height: 640,
Height: WindowHeight,
Hidden: true,
DisableResize: true,
MinimiseButtonState: application.ButtonHidden,
@@ -187,8 +237,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
CloseButtonState: application.ButtonEnabled,
BackgroundColour: WindowBackgroundColour,
URL: "/#/settings",
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
Linux: LinuxAppearanceOptions(linuxIcon),
})
// Hide on close instead of destroying — preserves in-window React state
// across reopens. Mirrors the main window's close behaviour. Resetting
@@ -203,31 +254,6 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
return s
}
// WatchLanguage subscribes to UI preference changes and re-applies the
// localised title to every live auxiliary window whenever the language
// flips. The eagerly-created Settings window outlives its first paint, so
// without this its title would stay frozen in the language it was created
// in; the on-demand dialog windows (BrowserLogin, Session*, InstallProgress)
// can also coexist with a Settings-driven language change. Safe to call
// once at startup; subsequent calls overwrite the previous subscription.
// No-op when sub is nil.
func (s *WindowManager) WatchLanguage(sub LanguageSubscriber) {
if sub == nil {
return
}
ch, _ := sub.Subscribe()
go func() {
var last i18n.LanguageCode
for p := range ch {
if p.Language == "" || p.Language == last {
continue
}
last = p.Language
s.retitleAll()
}
}()
}
// retitleAll re-applies the localised title to every currently-alive
// auxiliary window. Reads the window pointers under s.mu so a concurrent
// Open*/Close* can't observe a torn slice. SetTitle itself dispatches to
@@ -244,6 +270,7 @@ func (s *WindowManager) retitleAll() {
{s.sessionExpired, "window.title.sessionExpired"},
{s.sessionAboutToExpire, "window.title.sessionExpiring"},
{s.installProgress, "window.title.updating"},
{s.welcome, "window.title.welcome"},
}
s.mu.Unlock()
for _, p := range wins {
@@ -271,6 +298,10 @@ func (s *WindowManager) OpenSettings(tab string) {
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
// Re-center on every open (minimal-WM only): like the main window,
// Settings is hidden (not destroyed) on close, and a hide -> show
// round-trip lands it back in the corner there unless re-centered.
s.centerWhenReady(s.settings)
}
// OpenBrowserLogin shows the SSO popup window, creating it on first use (and
@@ -295,7 +326,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
screen = sc
}
}
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL)
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
// SSO popup deliberately is NOT always-on-top — the user moves
// between the browser tab and our popup; pinning it would obscure
// the browser at the moment they need to interact with it.
@@ -320,7 +351,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
// First open: window is Hidden, the React side auto-sizes via
// useAutoSizeWindow and calls Window.Show/Focus once content is
// measured. Returning here avoids the snap from placeholder to
// measured height.
// measured height. centerWhenReady polls for that JS-driven show,
// so it centers (minimal-WM only) whoever ends up calling Show.
s.centerWhenReady(s.browserLogin)
return
}
if uri != "" {
@@ -328,6 +361,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
}
s.browserLogin.Show()
s.browserLogin.Focus()
s.centerWhenReady(s.browserLogin)
}
// hideOtherWindowsLocked hides every currently visible window except the one
@@ -405,17 +439,19 @@ func (s *WindowManager) OpenSessionExpired() {
defer s.mu.Unlock()
if s.sessionExpired == nil {
s.sessionExpired = s.app.Window.NewWithOptions(
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired"),
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired", s.linuxIcon),
)
s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.sessionExpired = nil
s.mu.Unlock()
})
s.centerWhenReady(s.sessionExpired)
return
}
s.sessionExpired.Show()
s.sessionExpired.Focus()
s.centerWhenReady(s.sessionExpired)
}
// CloseSessionExpired destroys the session-expired window if open.
@@ -440,18 +476,20 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds)
if s.sessionAboutToExpire == nil {
s.sessionAboutToExpire = s.app.Window.NewWithOptions(
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL),
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL, s.linuxIcon),
)
s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.sessionAboutToExpire = nil
s.mu.Unlock()
})
s.centerWhenReady(s.sessionAboutToExpire)
return
}
s.sessionAboutToExpire.SetURL(startURL)
s.sessionAboutToExpire.Show()
s.sessionAboutToExpire.Focus()
s.centerWhenReady(s.sessionAboutToExpire)
}
// CloseSessionAboutToExpire destroys the countdown warning window if open.
@@ -486,7 +524,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
if s.installProgress == nil {
s.hideOtherWindowsLocked("install-progress")
s.installProgress = s.app.Window.NewWithOptions(
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL),
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon),
)
s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
@@ -494,11 +532,13 @@ func (s *WindowManager) OpenInstallProgress(version string) {
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
})
s.centerWhenReady(s.installProgress)
return
}
s.installProgress.SetURL(startURL)
s.installProgress.Show()
s.installProgress.Focus()
s.centerWhenReady(s.installProgress)
}
// CloseInstallProgress destroys the install-progress window if open.
@@ -511,3 +551,117 @@ func (s *WindowManager) CloseInstallProgress() {
w.Close()
}
}
// OpenWelcome shows the first-launch onboarding window. The React side
// auto-sizes the window height to its content; the Continue button calls
// Preferences.SetOnboardingCompleted(true) before closing so the flow
// doesn't re-run. Singleton, destroyed on close. Created Hidden so the
// React side can auto-size before paint.
func (s *WindowManager) OpenWelcome() {
s.mu.Lock()
defer s.mu.Unlock()
if s.welcome == nil {
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
opts.Width = 420
// Onboarding stays AlwaysOnTop (inherited from DialogWindowOptions)
// so the user can't accidentally bury the first-launch flow behind
// another window and lose track of how to finish setup.
// Land in the middle of the user's primary display — the welcome
// flow is identity-defining and shouldn't read as an incidental
// dialog floating in a corner. WindowCentered + nil Screen
// resolves against the primary display (see WebviewWindowOptions).
opts.InitialPosition = application.WindowCentered
s.welcome = s.app.Window.NewWithOptions(opts)
w := s.welcome
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.welcome = nil
s.mu.Unlock()
})
s.centerWhenReady(s.welcome)
return
}
s.welcome.Show()
s.welcome.Focus()
s.centerWhenReady(s.welcome)
}
// CloseWelcome destroys the welcome window if open.
func (s *WindowManager) CloseWelcome() {
s.mu.Lock()
w := s.welcome
s.welcome = nil
s.mu.Unlock()
if w != nil {
w.Close()
}
}
// OpenMain brings the main window forward. Used by the welcome Continue
// button to hand off from onboarding to the regular UI without depending
// on the tray.
func (s *WindowManager) OpenMain() {
s.ShowMain()
}
// ShowMain brings the main window forward, centering it on each show (see
// centerWhenReady). The single entry point every surface — tray, SIGUSR1,
// welcome handoff — should use so the centering fix applies uniformly.
func (s *WindowManager) ShowMain() {
if s.mainWindow == nil {
return
}
s.mainWindow.Show()
s.mainWindow.Focus()
// Re-center on every show (minimal-WM only — see centerWhenReady). The
// window is hidden (not destroyed) on close, and on a hide -> show
// round-trip minimal WMs (the XEmbed tray path) re-place it in the
// top-left corner rather than restoring its prior position, so
// re-opening from the tray lands it in the corner again otherwise.
s.centerWhenReady(s.mainWindow)
}
// SetRecenterOnShow installs the predicate that gates Go-side re-centering of
// the main and Settings windows (see the recenterOnShow field). The Linux
// startup path passes xembedTrayAvailable so re-centering happens only in the
// minimal-WM / in-process-XEmbed-tray environment; macOS/Windows and tests
// leave it unset, making centerWhenReady a no-op.
func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
s.recenterOnShow = pred
}
// centerWhenReady centers w once its native window actually exists — but only
// in environments where the WM won't do it for us (recenterOnShow). On full
// desktops the WM centers small windows and restores position across hide ->
// show, so this returns immediately and never fights a user-moved window.
//
// Why it can't be a simple inline Center() after Show(): on Linux/GTK4 (Wails'
// linux_cgo backend) Center() moves the window via raw X11 (window_move_x11),
// which silently no-ops while the GdkSurface is still nil — and GTK4 realizes
// the surface asynchronously on the main loop, *after* Show() returns. So an
// immediate Center() races realization and lands in the top-left corner; the
// minimal WMs this targets don't re-center for us, so it sticks.
//
// It also can't be deferred via InvokeAsync(w.Center): Center() itself hops to
// the main thread with InvokeSync, so running it *on* the main thread would
// deadlock. So we drive it from a background goroutine (Center() and Position()
// are main-thread-safe off-thread for exactly that reason) and retry until the
// move actually takes effect, which is the unambiguous signal that the surface
// now exists: position() goes through X11 (window_get_position_x11) and reports
// (0,0) while the surface is nil — so a non-zero post-Center position means the
// centering landed. Bounded so a window that legitimately centers at the origin
// (e.g. fills the monitor) can't spin forever.
func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) {
if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() {
return
}
go func() {
for i := 0; i < 50; i++ { // ~1s budget at 20ms steps
w.Center()
if x, y := w.Position(); x != 0 || y != 0 {
return // move took effect -> surface is realized
}
time.Sleep(20 * time.Millisecond)
}
}()
}

View File

@@ -112,7 +112,7 @@ type Tray struct {
// connected, the last status string, the daemon version, the
// routed-networks revision, and the post-connect login-trigger flag.
// These are all written by applyStatus and read by the menu painters
// (applyIcon, reapplyMenuState, refreshExitNodes' connected sample,
// (applyIcon, relayoutMenu, refreshExitNodes' connected sample,
// etc.). One mutex covers them because they change together on every
// Status push.
statusMu sync.Mutex
@@ -170,8 +170,24 @@ type Tray struct {
// callers.
profileLoadMu sync.Mutex
// profilesMu guards the cached profile rows that relayoutMenu repaints
// into a freshly built Profiles submenu. loadProfiles fetches and stores
// them here; fillProfileSubmenu reads them. Kept separate from the live
// submenu so a relayout (which throws the old submenu away) always has a
// source of truth to repaint from without re-hitting the daemon.
profilesMu sync.Mutex
profiles []services.Profile
profilesUser string
// menuMu serialises relayoutMenu — the full buildMenu + SetMenu cycle.
// loadProfiles (under profileLoadMu) and refreshExitNodes (under
// exitNodesRebuildMu) both drive a relayout from independent mutexes, and
// applyLanguage drives one from the Localizer goroutine; without this guard
// two relayouts could interleave their t.menu swap and SetMenu push.
menuMu sync.Mutex
// exitNodesMu guards the t.exitNodes row cache so reading the cached
// rows in reapplyMenuState (and tearing a copy off the slice for
// rows in relayoutMenu (and tearing a copy off the slice for
// Repaint) doesn't contend with status-push readers of statusMu.
exitNodesMu sync.Mutex
// exitNodes are the rows currently painted into the Exit Node
@@ -196,7 +212,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// the right locale — no English flash followed by a re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() })
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection (Linux only) before the first paint so the
// initial icon already matches the panel's light/dark scheme; repaints
@@ -293,6 +309,13 @@ func (t *Tray) ShowWindow() {
if t.window == nil {
return
}
// Route through WindowManager so the main window is centered on its
// first show (see WindowManager.ShowMain) — minimal WMs (fluxbox, the
// XEmbed tray path) otherwise drop it in the top-left corner.
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
t.window.Show()
t.window.Focus()
}
@@ -309,17 +332,36 @@ func (t *Tray) applyLanguage() {
if runtime.GOOS == "linux" {
t.tray.SetLabel(t.loc.T("tray.tooltip"))
}
t.menu = t.buildMenu()
t.tray.SetMenu(t.menu)
t.reapplyMenuState()
t.relayoutMenu()
}
// reapplyMenuState walks cached state and re-applies the visibility,
// enablement and label mutations that applyStatus would have performed
// since the last menu rebuild. Required after buildMenu because that
// constructor returns items in their default (disconnected) shape. The
// update menu item is re-applied by trayUpdater.applyLanguage.
func (t *Tray) reapplyMenuState() {
// relayoutMenu rebuilds the ENTIRE tray menu from scratch (buildMenu), repaints
// the cached status/session/profile/exit-node state into the fresh items, and
// pushes the whole tree with a single SetMenu. It is the only Linux path that
// reliably propagates submenu changes.
//
// Why a full rebuild rather than mutating the existing submenu in place: on
// KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first
// time it is opened (GetLayout for that submenu id) and never re-fetches it on
// a LayoutUpdated(parent=0) signal — so Clear()+Add() into the same submenu
// container left the visible menu (and, worse, the click→id mapping) frozen on
// the first snapshot: clicks sent the stale ids, which the freshly-rebuilt
// itemMap no longer knew, so they silently no-op'd. buildMenu allocates a brand
// new submenu container id every time, which Plasma treats as an unseen menu
// and re-queries on next open — both the labels and the click ids stay live.
// (Confirmed via dbus-monitor: a re-opened submenu issued no GetLayout until
// its container id changed.) The darwin detached-NSMenu workaround that the old
// per-submenu SetMenu addressed is also covered, since this rebuilds the whole
// tree against the cached top-level pointer.
//
// Pulls profile/exit-node rows from their caches (profilesMu / exitNodes) so it
// never re-hits the daemon and never recurses back into loadProfiles.
func (t *Tray) relayoutMenu() {
t.menuMu.Lock()
defer t.menuMu.Unlock()
t.menu = t.buildMenu()
t.statusMu.Lock()
connected := t.connected
lastStatus := t.lastStatus
@@ -374,15 +416,19 @@ func (t *Tray) reapplyMenuState() {
if t.updater != nil {
t.updater.applyLanguage()
}
// buildMenu just recreated an empty Exit Node submenu, so repaint the
// cached rows unconditionally (a refreshExitNodes would skip the rebuild
// when the list is unchanged). Hold exitNodesRebuildMu so this rebuild
// can't race a status-push-driven refreshExitNodes mutating the same
// submenu.
t.exitNodesRebuildMu.Lock()
t.rebuildExitNodes(exitNodeEntries)
t.exitNodesRebuildMu.Unlock()
go t.loadProfiles()
// buildMenu just recreated empty Profiles + Exit Node submenus, so repaint
// both from their caches before the single SetMenu below. fillExitNodeSubmenu
// uses the entries snapshotted above; fillProfileSubmenu reads profilesMu.
// Neither re-fetches, so relayoutMenu never recurses back into
// loadProfiles/refreshExitNodes. (We must NOT re-take exitNodesRebuildMu
// here — refreshExitNodes already holds it when it calls relayoutMenu.)
t.fillExitNodeSubmenu(exitNodeEntries)
t.fillProfileSubmenu()
// Single push of the whole tree. On Linux this emits one LayoutUpdated with
// fresh submenu container ids; on darwin it rebuilds the NSMenu against the
// cached top-level pointer.
t.tray.SetMenu(t.menu)
}
func (t *Tray) buildMenu() *application.Menu {
@@ -462,7 +508,7 @@ func (t *Tray) buildMenu() *application.Menu {
// exitNodeSubmenu hosts one row per peer advertising a default
// route (0.0.0.0/0 or ::/0). Populated asynchronously by
// rebuildExitNodes on every Status push that changes the set;
// refreshExitNodes (via relayoutMenu) on every Status push that changes the set;
// the parent row stays disabled until at least one candidate is
// known. We grab the parent MenuItem via FindByLabel (same
// pattern as the Profiles submenu) so applyStatus can flip its
@@ -606,4 +652,3 @@ func (t *Tray) notify(title, body, id string) {
func (t *Tray) notifyError(message string) {
t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError)
}

Some files were not shown because too many files have changed in this diff Show More